284 lines
19 KiB
TypeScript
284 lines
19 KiB
TypeScript
import { createRequire } from 'node:module'
|
||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { ElMessageBox } from 'element-plus'
|
||
import MessageComposerDialog from './components/MessageComposerDialog.vue'
|
||
import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
|
||
import PlatformNotificationsView from './pages/PlatformNotificationsView.vue'
|
||
import MessageManagementView from './pages/MessageManagementView.vue'
|
||
import MessageTemplatesView from './pages/MessageTemplatesView.vue'
|
||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import { ApiResponseError } from '@/shared/api/http'
|
||
import * as api from './api/notificationCenter'
|
||
import type { NotificationInboxDetail, NotificationTask, NotificationTaskDetail, NotificationTemplate, PageResult } from './types'
|
||
|
||
const { default: ElementPlus, ElDialog, ElDrawer, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||
const can = vi.hoisted(() => vi.fn(() => true))
|
||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||
vi.mock('./api/notificationCenter', () => ({
|
||
listMyNotifications: vi.fn(), getMyUnreadNotificationCount: vi.fn(), getMyNotification: vi.fn(),
|
||
markMyNotificationRead: vi.fn(), markAllMyNotificationsRead: vi.fn(), listTasks: vi.fn(), getTask: vi.fn(),
|
||
listTemplates: vi.fn(), createNotification: vi.fn(), createTemplate: vi.fn(), updateTemplate: vi.fn(),
|
||
cancelTask: vi.fn(), retryTask: vi.fn(), changeTemplateStatus: vi.fn(),
|
||
}))
|
||
const template: NotificationTemplate = { id: '9223372036854775001', templateCode: 'ORDER_NOTICE', templateName: '订单通知', category: 'ORDER', channel: 'IN_APP', titleTemplate: '订单更新', bodyTemplate: '订单已更新', variableWhitelist: [], status: 'DRAFT', version: 7 }
|
||
const task: NotificationTask = { id: '9223372036854775002', taskNo: 'NT202610200001', category: 'ORDER', title: '订单更新', status: 'CREATED', recipientCount: 0, scheduledAt: null, sentAt: null, duplicated: false }
|
||
const taskDetail: NotificationTaskDetail = { ...task, attemptCount: 0, lastError: null, recipients: [] }
|
||
const inbox: NotificationInboxDetail = { id: '9223372036854775003', category: 'ORDER', title: '订单更新', summary: '更新', body: '第一行\n第二行', status: 'UNREAD', sentAt: '2026-10-20T01:00:00Z', readAt: null, sourceType: null, sourceId: null }
|
||
const page = <T,>(records: T[]): PageResult<T> => ({ records, page: 1, size: 20, total: records.length, pages: 1 })
|
||
const options = () => ({
|
||
attachTo: document.body,
|
||
global: {
|
||
plugins: [ElementPlus],
|
||
stubs: {
|
||
PlatformTenantSelector: {
|
||
props: ['modelValue', 'disabled', 'label'], emits: ['update:modelValue'],
|
||
template: '<input aria-label="目标企业" :disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||
},
|
||
},
|
||
},
|
||
})
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void
|
||
let reject!: (reason: unknown) => void
|
||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||
return { promise, resolve, reject }
|
||
}
|
||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||
expect(button, text).toBeDefined()
|
||
await button!.trigger('click')
|
||
await flushPromises()
|
||
}
|
||
async function composer(variables: string[] = []) {
|
||
const wrapper = mount(MessageComposerDialog, { ...options(), props: { modelValue: true, templates: [{ ...template, status: 'ACTIVE', variableWhitelist: variables }] } })
|
||
await flushPromises()
|
||
wrapper.findAllComponents(ElSelect)[0]!.vm.$emit('update:modelValue', template.templateCode)
|
||
await flushPromises()
|
||
return wrapper
|
||
}
|
||
async function editor() {
|
||
const wrapper = mount(TemplateEditorDialog, { ...options(), props: { modelValue: true, template } })
|
||
await flushPromises()
|
||
return wrapper
|
||
}
|
||
const failure = () => new ApiResponseError('测试拒绝保存', 'TEST_REJECTED', 409, null, 'notification-test-request')
|
||
enableAutoUnmount(afterEach)
|
||
beforeEach(() => {
|
||
vi.resetAllMocks()
|
||
can.mockReturnValue(true)
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||
vi.mocked(api.listMyNotifications).mockResolvedValue(page([inbox]))
|
||
vi.mocked(api.getMyUnreadNotificationCount).mockResolvedValue(1)
|
||
vi.mocked(api.getMyNotification).mockResolvedValue(inbox)
|
||
vi.mocked(api.markMyNotificationRead).mockResolvedValue({ ...inbox, status: 'READ' })
|
||
vi.mocked(api.listTasks).mockResolvedValue(page([task]))
|
||
vi.mocked(api.getTask).mockResolvedValue(taskDetail)
|
||
vi.mocked(api.listTemplates).mockResolvedValue(page([template]))
|
||
vi.mocked(api.createNotification).mockResolvedValue(task)
|
||
vi.mocked(api.createTemplate).mockResolvedValue(template)
|
||
vi.mocked(api.updateTemplate).mockResolvedValue(template)
|
||
vi.mocked(api.cancelTask).mockResolvedValue({ ...task, status: 'CANCELLED' })
|
||
vi.mocked(api.retryTask).mockResolvedValue({ ...task, status: 'RETRYING' })
|
||
vi.mocked(api.changeTemplateStatus).mockResolvedValue({ ...template, status: 'ACTIVE' })
|
||
})
|
||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||
|
||
describe('notification native dialogs and drawers', () => {
|
||
it('keeps fullscreen drafts and clears disabled scheduling before platform send', async () => {
|
||
const wrapper = await composer()
|
||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue(' TEST_OPERATOR ')
|
||
await wrapper.get('[role="switch"]').trigger('click')
|
||
await wrapper.get('[role="switch"]').trigger('click')
|
||
await wrapper.get('button[aria-label="展开全屏"]').trigger('click')
|
||
expect(wrapper.getComponent(ElDialog).props('fullscreen')).toBe(true)
|
||
expect((wrapper.get('input[placeholder="请输入服务端角色编码"]').element as HTMLInputElement).value).toBe(' TEST_OPERATOR ')
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', scheduledAt: null, variables: {}, idempotencyKey: expect.any(String) }))
|
||
})
|
||
it('retains numeric long recipient IDs, removes duplicates and omits role fields', async () => {
|
||
const wrapper = await composer()
|
||
wrapper.findAllComponents(ElSelect)[1]!.vm.$emit('update:modelValue', 'USER')
|
||
await flushPromises()
|
||
await wrapper.get('textarea').setValue('9223372036854775000,9223372036854775001\n9223372036854775000')
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ recipientType: 'USER', recipientIds: ['9223372036854775000', '9223372036854775001'] }))
|
||
expect(vi.mocked(api.createNotification).mock.calls[0]![0]).not.toHaveProperty('roleCode')
|
||
})
|
||
it.each(['9223372036854775808', '123x', ''])('rejects invalid explicit recipients %s', async text => {
|
||
const wrapper = await composer()
|
||
wrapper.findAllComponents(ElSelect)[1]!.vm.$emit('update:modelValue', 'USER')
|
||
await flushPromises()
|
||
await wrapper.get('textarea').setValue(text)
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).not.toHaveBeenCalled()
|
||
})
|
||
it('keeps customer selection, variable whitelist and scheduled request intact', async () => {
|
||
const wrapper = await composer(['order_no'])
|
||
wrapper.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', 'customer')
|
||
await flushPromises()
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).not.toHaveBeenCalled()
|
||
await wrapper.get('input[aria-label="目标企业"]').setValue('9223372036854775100')
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).not.toHaveBeenCalled()
|
||
await wrapper.get('input[placeholder="请输入变量值"]').setValue('NYEXX2610200001')
|
||
await wrapper.get('[role="switch"]').trigger('click')
|
||
await click(wrapper, '确认定时发送')
|
||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '9223372036854775100', recipientType: 'TENANT_ALL', variables: { order_no: 'NYEXX2610200001' }, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) }))
|
||
})
|
||
it('keeps long template variable labels above their editable values', async () => {
|
||
const variable = 'customer_delivery_appointment_reference_number'
|
||
const wrapper = await composer([variable])
|
||
const field = wrapper.get('.message-variable-field')
|
||
expect(field.classes()).toContain('el-form-item--label-top')
|
||
expect(field.get('label').text()).toContain(variable)
|
||
expect((field.get('label').element as HTMLElement).style.width).not.toBe('100px')
|
||
await field.get('input[placeholder="请输入变量值"]').setValue('REFERENCE-20261021')
|
||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ variables: { [variable]: 'REFERENCE-20261021' } }))
|
||
})
|
||
it('locks composer close paths and retains the draft after a rejected send', async () => {
|
||
const pending = deferred<NotificationTask>()
|
||
vi.mocked(api.createNotification).mockReturnValue(pending.promise)
|
||
const wrapper = await composer()
|
||
await wrapper.get('input[placeholder="请输入服务端角色编码"]').setValue('TEST_OPERATOR')
|
||
await click(wrapper, '立即发送')
|
||
expect(wrapper.getComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||
expect(wrapper.get('input[placeholder="请输入服务端角色编码"]').attributes('disabled')).toBeDefined()
|
||
await click(wrapper, '取消')
|
||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||
pending.reject(failure())
|
||
await flushPromises()
|
||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝保存', requestId: 'notification-test-request' })
|
||
expect((wrapper.get('input[placeholder="请输入服务端角色编码"]').element as HTMLInputElement).value).toBe('TEST_OPERATOR')
|
||
await click(wrapper, '立即发送')
|
||
expect(api.createNotification).toHaveBeenCalledTimes(2)
|
||
})
|
||
it('creates a normalized draft and inserts a whitelisted variable at the cursor', async () => {
|
||
const wrapper = mount(TemplateEditorDialog, { ...options(), props: { modelValue: true } })
|
||
await flushPromises()
|
||
await wrapper.get('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').setValue('order_notice')
|
||
await wrapper.get('input[placeholder="请输入模板名称"]').setValue('测试模板')
|
||
await wrapper.get('input[placeholder="请输入业务分类"]').setValue('ORDER')
|
||
await wrapper.get('input[placeholder="请输入消息标题模板"]').setValue('测试标题')
|
||
await wrapper.get('textarea').setValue('前后')
|
||
;(wrapper.get('textarea').element as HTMLTextAreaElement).setSelectionRange(1, 1)
|
||
await click(wrapper, 'order_no')
|
||
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('前${order_no}后')
|
||
expect(wrapper.get('.template-preview').text()).toContain('前${order_no}后')
|
||
await click(wrapper, '保存模板')
|
||
expect(api.createTemplate).toHaveBeenCalledWith({ templateCode: 'ORDER_NOTICE', templateName: '测试模板', category: 'ORDER', titleTemplate: '测试标题', bodyTemplate: '前${order_no}后', variableWhitelist: ['order_no'] })
|
||
})
|
||
it('keeps template code plain and preserves version and draft across fullscreen', async () => {
|
||
const wrapper = await editor()
|
||
expect(wrapper.find('input[placeholder="如:ORDER_SHIPPED_NOTICE"]').exists()).toBe(false)
|
||
expect(wrapper.get('.density-table tr:first-child').text()).toContain(template.templateCode)
|
||
await wrapper.get('input[placeholder="请输入模板名称"]').setValue('改名')
|
||
await wrapper.get('button[aria-label="展开全屏"]').trigger('click')
|
||
expect(wrapper.getComponent(ElDialog).props('fullscreen')).toBe(true)
|
||
await click(wrapper, '保存模板')
|
||
expect(api.updateTemplate).toHaveBeenCalledWith(template.id, expect.objectContaining({ templateName: '改名', version: 7 }))
|
||
expect(vi.mocked(api.updateTemplate).mock.calls[0]![1]).not.toHaveProperty('templateCode')
|
||
})
|
||
it('validates template content and whitelist before saving', async () => {
|
||
const wrapper = await editor()
|
||
await wrapper.get('textarea').setValue('')
|
||
await click(wrapper, '保存模板')
|
||
expect(api.updateTemplate).not.toHaveBeenCalled()
|
||
await wrapper.get('textarea').setValue('消息内容')
|
||
await wrapper.get('input[placeholder^="多个变量"]').setValue('INVALID')
|
||
await click(wrapper, '保存模板')
|
||
expect(api.updateTemplate).not.toHaveBeenCalled()
|
||
})
|
||
it('locks template controls and retains rejected content and diagnostics', async () => {
|
||
const pending = deferred<NotificationTemplate>()
|
||
vi.mocked(api.updateTemplate).mockReturnValue(pending.promise)
|
||
const wrapper = await editor()
|
||
await wrapper.get('textarea').setValue('保留正文\n第二行')
|
||
await click(wrapper, '保存模板')
|
||
expect(wrapper.getComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
|
||
pending.reject(failure())
|
||
await flushPromises()
|
||
expect(wrapper.get('.template-preview').text()).toContain('保留正文\n第二行')
|
||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'notification-test-request' })
|
||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||
})
|
||
it('renders long inbox text without exposing source IDs in read-only mode', async () => {
|
||
can.mockReturnValue(false)
|
||
const body = '完整正文'.repeat(1000) + '\n结束'
|
||
vi.mocked(api.getMyNotification).mockResolvedValue({ ...inbox, body, sourceId: 'internal-source-id' })
|
||
const wrapper = mount(PlatformNotificationsView, options())
|
||
await flushPromises()
|
||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||
const drawer = document.querySelector('.admin-density-drawer--detail')!
|
||
expect(drawer.querySelector('.message-body')!.textContent).toBe(body)
|
||
expect(drawer.textContent).not.toContain('internal-source-id')
|
||
expect(drawer.querySelectorAll('input, textarea')).toHaveLength(0)
|
||
expect(api.markMyNotificationRead).not.toHaveBeenCalled()
|
||
})
|
||
it.each(['inbox', 'task'] as const)('retries the same %s detail after a load error', async kind => {
|
||
const get = kind === 'inbox' ? vi.mocked(api.getMyNotification) : vi.mocked(api.getTask)
|
||
get.mockRejectedValueOnce(failure())
|
||
const wrapper = mount(kind === 'inbox' ? PlatformNotificationsView : MessageManagementView, options())
|
||
await flushPromises()
|
||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||
const retry = document.querySelector('.el-drawer .load-error-state__actions button') as HTMLButtonElement
|
||
expect(retry).not.toBeNull()
|
||
retry.click()
|
||
await flushPromises()
|
||
expect(get).toHaveBeenCalledTimes(2)
|
||
expect(document.querySelector('.el-drawer .load-error-state')).toBeNull()
|
||
expect(document.querySelector('.el-drawer .density-dialog-section')).not.toBeNull()
|
||
})
|
||
it.each(['inbox', 'task'] as const)('ignores closed %s detail responses', async kind => {
|
||
const pendingInbox = deferred<NotificationInboxDetail>()
|
||
const pendingTask = deferred<NotificationTaskDetail>()
|
||
if (kind === 'inbox') vi.mocked(api.getMyNotification).mockReturnValue(pendingInbox.promise)
|
||
else vi.mocked(api.getTask).mockReturnValue(pendingTask.promise)
|
||
const wrapper = mount(kind === 'inbox' ? PlatformNotificationsView : MessageManagementView, options())
|
||
await flushPromises()
|
||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||
wrapper.getComponent(ElDrawer).vm.$emit('update:modelValue', false)
|
||
await flushPromises()
|
||
pendingInbox.resolve(inbox)
|
||
pendingTask.resolve(taskDetail)
|
||
await flushPromises()
|
||
expect(wrapper.getComponent(ElDrawer).props('modelValue')).toBe(false)
|
||
expect(api.markMyNotificationRead).not.toHaveBeenCalled()
|
||
})
|
||
it('retains zero task facts and all delivery failure text', async () => {
|
||
const reason = '完整失败原因'.repeat(200)
|
||
vi.mocked(api.getTask).mockResolvedValue({ ...taskDetail, taskNo: '', lastError: reason, recipients: [{ id: 'internal-recipient-id', displayName: '测试员工', appCode: 'ADMIN', status: 'FAILED', deliveredAt: null, failureReason: reason }] })
|
||
const wrapper = mount(MessageManagementView, options())
|
||
await flushPromises()
|
||
await click(wrapper, '查看', '.el-table__body-wrapper button')
|
||
const drawer = document.querySelector('.admin-density-drawer--detail')!
|
||
const facts = [...drawer.querySelectorAll('.task-facts td')].map(item => item.textContent)
|
||
expect(facts).toEqual(['—', '已创建', '订单消息', '0', '0', '—'])
|
||
expect(drawer.querySelector('.el-alert .message-body')!.textContent).toBe(reason)
|
||
expect(drawer.querySelector('.el-table__body .message-body')!.textContent).toBe(reason)
|
||
expect(drawer.textContent).not.toContain('internal-recipient-id')
|
||
})
|
||
it.each(['cancel', 'retry'] as const)('uses task business number and the existing %s command', async action => {
|
||
vi.mocked(api.listTasks).mockResolvedValue(page([{ ...task, status: action === 'cancel' ? 'CREATED' : 'DEAD' }]))
|
||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(Object.assign('confirm', { value: '', action: 'confirm' as const }))
|
||
const wrapper = mount(MessageManagementView, options())
|
||
await flushPromises()
|
||
await click(wrapper, action === 'cancel' ? '取消' : '重试', '.el-table__body-wrapper button')
|
||
expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(task.taskNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench' }))
|
||
expect(action === 'cancel' ? api.cancelTask : api.retryTask).toHaveBeenCalledWith(task.id)
|
||
})
|
||
it.each(['DRAFT', 'ACTIVE'] as const)('keeps template %s confirmation and version', async status => {
|
||
vi.mocked(api.listTemplates).mockResolvedValue(page([{ ...template, status }]))
|
||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(Object.assign('confirm', { value: '', action: 'confirm' as const }))
|
||
const wrapper = mount(MessageTemplatesView, options())
|
||
await flushPromises()
|
||
await click(wrapper, status === 'DRAFT' ? '激活' : '禁用', '.el-table__body-wrapper button')
|
||
expect(confirm).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(template.templateName), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench' }))
|
||
expect(api.changeTemplateStatus).toHaveBeenCalledWith(template.id, status === 'DRAFT' ? 'ACTIVE' : 'DISABLED', 7)
|
||
})
|
||
})
|