import { fireEvent, render, screen, waitFor } 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', recipientCount: 1, recipients: [{ alias: '测试员工', userId: 'ding-user-1' }], 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() beforeEach(() => { jest.clearAllMocks() Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) }, }) global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url.includes('/api/admin/notification-center/projects?includeRecipients=1') && (!init?.method || init.method === 'GET')) { return jsonResponse({ success: true, data: { projects: [project] } }) } if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') { return jsonResponse({ success: true, data: { project, 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('/recipients') && init?.method === 'PUT') { return jsonResponse({ project }) } 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.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.getByText(/素材平台向外部系统发送 Webhook 的能力位于“素材外发”/)).toBeInTheDocument() expect(screen.getAllByText('1', { selector: 'span' })).toHaveLength(3) 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.type(screen.getByLabelText('项目中文名称'), '固定资产系统') await user.type(screen.getByLabelText('projectCode'), 'fixed_assets') await user.type(screen.getByLabelText('允许跳转域名 allowedHosts'), 'https://assets.example.com') await user.click(screen.getByRole('button', { name: '创建并生成密钥' })) expect(await screen.findByText('one-time-project-secret')).toBeInTheDocument() expect(screen.getByText(/YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_assets/)).toBeInTheDocument() expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects', expect.objectContaining({ method: 'POST' }), ) }) test('保存花名使用项目专属 recipients 代理接口', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: /收件人花名/ })) expect(await screen.findByDisplayValue('测试员工')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '保存花名清单' })) await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/admin/notification-center/projects/material_library/recipients', expect.objectContaining({ method: 'PUT' }), ) }) }) 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(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument() await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' })) expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument() }) test('测试通知在确认前不发送,确认后才调用 mock 接口', async () => { const user = userEvent.setup() render() await screen.findByText('素材平台') await user.click(screen.getByRole('button', { name: /测试通知/ })) fireEvent.change(screen.getByLabelText('真实收件人'), { target: { value: '测试员工' } }) await user.click(screen.getByRole('button', { name: '向指定员工发送测试通知' })) expect(global.fetch).not.toHaveBeenCalledWith(expect.stringContaining('/test'), expect.anything()) 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' }), ) }) }) test('通知中心离线时显示可恢复错误且不伪装成空数据', async () => { global.fetch = jest.fn().mockRejectedValue(new Error('连接超时')) render() expect(await screen.findByRole('alert')).toHaveTextContent('连接超时') expect(screen.getByRole('alert')).toHaveTextContent('项目配置没有被修改') }) })