Files
admin-web/src/modules/notifications/NotificationDialogs.test.ts
T

284 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})