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 = (records: T[]): PageResult => ({ 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: '', }, }, }, }) function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((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() 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() 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() const pendingTask = deferred() 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) }) })