import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import NotificationCenterPanel from './NotificationCenterPanel' jest.mock('@gsap/react', () => ({ useGSAP: jest.fn(), })) jest.mock('gsap', () => ({ registerPlugin: jest.fn(), matchMedia: () => ({ add: jest.fn(), revert: jest.fn(), }), fromTo: jest.fn(), })) const project = { name: '素材平台', projectCode: 'material_library', isActive: true, allowedHosts: ['https://sc.jiansun.vip'], recipientMode: 'alias', updatedAt: '2026-08-28T08:00:00.000Z', deliverySummary: { lastStatus: 'sent', lastSentAt: '2026-08-28T08:00:00.000Z', recentFailedCount: 0 }, } const jsonResponse = (body: unknown, status = 200) => Promise.resolve({ ok: status >= 200 && status < 300, status, json: async () => body, } as Response) describe('NotificationCenterPanel', () => { const showToast = jest.fn() let clipboardText = '' let projectList = [project] let deleteShouldFail = false beforeEach(() => { jest.clearAllMocks() clipboardText = '' projectList = [{ ...project }] deleteShouldFail = false Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockImplementation(async (value: string) => { clipboardText = value }), readText: jest.fn().mockImplementation(async () => clipboardText), }, }) Object.defineProperty(document, 'execCommand', { configurable: true, value: jest.fn().mockImplementation((command: string) => { if (command !== 'copy') return false clipboardText = document.querySelector('textarea[aria-hidden="true"]')?.textContent || (document.querySelector('textarea[aria-hidden="true"]') as HTMLTextAreaElement | null)?.value || '' return true }), }) global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url.endsWith('/api/admin/notification-center/projects') && (!init?.method || init.method === 'GET')) { return jsonResponse({ success: true, data: { projects: projectList } }) } if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') { return jsonResponse({ success: true, data: { projectCode: 'fixed_assets', secret: 'one-time-project-secret', env: { YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center', YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'fixed_assets', YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'one-time-project-secret', }, }, }, 201) } if (url.includes('/rotate-secret') && init?.method === 'POST') { return jsonResponse({ success: true, data: { secret: 'rotated-one-time-secret', env: { YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'rotated-one-time-secret' }, }, }) } if (url.includes('/reveal-secret') && init?.method === 'POST') { return jsonResponse({ success: true, data: { projectCode: 'material_library', secret: 'current-project-secret', env: { YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center', YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'material_library', YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret', }, }, }) } if (url.endsWith('/api/admin/notification-center/projects/material_library') && init?.method === 'DELETE') { if (deleteShouldFail) { return jsonResponse({ error: { message: '通知中心服务异常,请稍后重试' } }, 502) } projectList = [] return jsonResponse({ success: true, data: { projectCode: 'material_library', deleted: true } }) } if (url.endsWith('/test') && init?.method === 'POST') { return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202) } if (url.includes('/deliveries')) { return jsonResponse({ deliveries: [] }) } return jsonResponse({ error: { message: 'unexpected request' } }, 500) }) as jest.Mock }) test('默认展示极简接入总览并说明标题前缀与外部项目边界', async () => { render() expect(await screen.findByText('素材平台')).toBeInTheDocument() expect(screen.getAllByText(/通知中心会统一在标题前加上项目名称/)).toHaveLength(2) expect(screen.getAllByText(/正文、按钮和跳转地址由外部项目自行决定/)).toHaveLength(2) expect(screen.queryByRole('button', { name: /收件人花名/ })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: '新增接入项目' })).toBeInTheDocument() }) test('新建项目只填名称也会直接展示 URL、projectCode 和项目密钥', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '新增接入项目' })) await user.type(screen.getByLabelText('项目名称'), '固定资产系统') await user.click(screen.getByRole('button', { name: '创建并生成密钥' })) expect(await screen.findByText('one-time-project-secret')).toBeInTheDocument() expect(screen.getByText('https://sc.jiansun.vip/notification-center')).toBeInTheDocument() expect(screen.getByText('fixed_assets')).toBeInTheDocument() expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects', expect.objectContaining({ method: 'POST' }), ) }) test('高级设置允许按需自定义 projectCode', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '新增接入项目' })) await user.type(screen.getByLabelText('项目名称'), '固定资产系统') await user.click(screen.getByRole('button', { name: '高级设置:自定义 projectCode' })) await user.type(screen.getByLabelText('projectCode'), 'fixed_assets') await user.click(screen.getByRole('button', { name: '创建并生成密钥' })) const request = (global.fetch as jest.Mock).mock.calls.find( ([url, init]) => String(url) === '/api/admin/notification-center/projects' && (init as RequestInit)?.method === 'POST', ) expect(request).toBeTruthy() expect((request?.[1] as RequestInit).body).toContain('"projectCode":"fixed_assets"') }) test('密钥轮换要求二次确认且新密钥只在确认后展示', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) await user.click(screen.getByRole('button', { name: '查看当前密钥' })) expect(await screen.findByText('current-project-secret')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '轮换项目密钥' })) expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' })) expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument() expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: '关闭' })) expect(screen.getByRole('button', { name: '查看当前密钥' })).toBeInTheDocument() }) test('删除项目需要单独确认,成功后刷新列表并关闭抽屉', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) await user.click(screen.getByRole('button', { name: '删除项目' })) const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' }) expect(within(dialog).getByText('删除项目配置并使密钥立即失效,历史投递记录保留。')).toBeInTheDocument() expect( within(dialog).getByText('如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。'), ).toBeInTheDocument() await user.click(within(dialog).getByRole('button', { name: '确认删除项目' })) await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects/material_library', expect.objectContaining({ method: 'DELETE' }), ) }) await waitFor(() => { expect(screen.queryByRole('heading', { name: '查看配置 / 编辑资料' })).not.toBeInTheDocument() }) expect(await screen.findByText('还没有接入项目')).toBeInTheDocument() expect(showToast).toHaveBeenCalledWith('接入项目已删除,项目密钥已立即失效', 'success') }) test('删除项目失败时保留确认弹窗并提示错误 toast', async () => { deleteShouldFail = true const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) await user.click(screen.getByRole('button', { name: '删除项目' })) const dialog = await screen.findByRole('alertdialog', { name: '确认删除“素材平台”项目?' }) await user.click(within(dialog).getByRole('button', { name: '确认删除项目' })) expect(await within(dialog).findByRole('alert')).toHaveTextContent('通知中心服务异常,请稍后重试') expect(showToast).toHaveBeenCalledWith('通知中心服务异常,请稍后重试', 'error') expect(screen.getByRole('alertdialog', { name: '确认删除“素材平台”项目?' })).toBeInTheDocument() expect(screen.queryByText('还没有接入项目')).not.toBeInTheDocument() }) test('已接入项目可查看资料并按需查看、复制和隐藏当前密钥', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') expect(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })).toHaveTextContent('查看配置 / 编辑资料') await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) expect(screen.getByRole('heading', { name: '查看配置 / 编辑资料' })).toBeInTheDocument() expect(screen.getByLabelText('项目名称')).toHaveValue('素材平台') expect(screen.getByLabelText('projectCode')).toHaveValue('material_library') expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument() await user.click(screen.getByRole('button', { name: '查看当前密钥' })) expect(await screen.findByText('current-project-secret')).toBeInTheDocument() expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects/material_library/reveal-secret', expect.objectContaining({ method: 'POST', cache: 'no-store' }), ) await user.click(screen.getByRole('button', { name: '复制 素材平台 的当前项目密钥' })) expect(await navigator.clipboard.readText()).toBe('current-project-secret') expect(showToast).toHaveBeenCalledWith('项目密钥已复制', 'success') await user.click(screen.getByRole('button', { name: '隐藏密钥' })) expect(screen.queryByText('current-project-secret')).not.toBeInTheDocument() }) test('浏览器拒绝 Clipboard API 时回退到兼容复制', async () => { const user = userEvent.setup() Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockRejectedValue(new Error('NotAllowedError')), }, }) render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: '查看配置并编辑资料 素材平台' })) await user.click(screen.getByRole('button', { name: '查看当前密钥' })) await screen.findByText('current-project-secret') await user.click(screen.getByRole('button', { name: '复制 素材平台 的当前项目密钥' })) expect(document.execCommand).toHaveBeenCalledWith('copy') expect(clipboardText).toBe('current-project-secret') expect(showToast).toHaveBeenCalledWith('项目密钥已复制', 'success') }) test('测试通知支持直接输入准确花名并在确认后透传 recipientAlias', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: /测试通知/ })) expect(screen.getByDisplayValue('准确花名')).toBeInTheDocument() fireEvent.change(screen.getByLabelText('真实收件人'), { target: { value: '测试员工' } }) await user.click(screen.getByRole('button', { name: '向指定员工发送测试通知' })) expect(screen.getByText(/按当前花名册精确解析 花名:测试员工/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '确认发送给该员工' })) await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects/material_library/test', expect.objectContaining({ method: 'POST', body: JSON.stringify({ recipientAlias: '测试员工', title: '统一通知中心测试', content: '这是一条由超级管理员发起的链路测试通知。', actionTitle: '查看详情', }), }), ) }) }) test('通知中心离线时显示可恢复错误且不伪装成空数据', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('连接超时')) render() expect(await screen.findByRole('alert')).toHaveTextContent('连接超时') expect(screen.getByRole('alert')).toHaveTextContent('项目配置没有被修改') }) })