/** @jest-environment node */ jest.mock('@/lib/prisma', () => ({ prisma: { performanceSurveyEmployeeProfile: { findMany: jest.fn(), }, }, })) import { createNotificationCenterProject, deleteNotificationCenterProject, listNotificationCenterProjectRecipients, listNotificationCenterProjects, NotificationCenterAdminError, revealNotificationCenterProjectSecret, resolveNotificationCenterRecipientAlias, rotateNotificationCenterProjectSecret, sendNotificationCenterProjectTest, updateNotificationCenterProject, } from '@/lib/notification-center-admin' import { prisma } from '@/lib/prisma' describe('notification-center-admin client', () => { const actor = { userId: 'super-1', username: 'wuxie', realName: '无邪', email: 'wuxie@example.com', } beforeEach(() => { jest.restoreAllMocks() ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset() process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL = 'https://notify-admin.example.com' process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret' delete process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS global.fetch = jest.fn() }) test('精确花名解析只返回唯一有效的钉钉 userId', async () => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ { dingtalkUserId: ' ding-user-1 ' }, { dingtalkUserId: 'ding-user-1' }, { dingtalkUserId: null }, ]) await expect(resolveNotificationCenterRecipientAlias(' 测试员工 ')).resolves.toEqual({ recipientUserId: 'ding-user-1', }) expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({ where: { flowerName: '测试员工' }, select: { dingtalkUserId: true }, take: 5, }) }) test('花名对应多个不同钉钉 userId 时不返回模糊结果', async () => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ { dingtalkUserId: 'ding-user-1' }, { dingtalkUserId: 'ding-user-2' }, ]) await expect(resolveNotificationCenterRecipientAlias('重名员工')).resolves.toBeNull() }) test('列表查询会透传操作人身份并剥离 project/admin secret', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ items: [ { code: 'material_library', name: '素材库', projectSecret: 'should-not-return', env: 'SECRET=1', adminSecret: 'server-admin-secret', }, ], adminKey: 'server-admin-secret', }), }) const result = await listNotificationCenterProjects(actor) expect(result.status).toBe(200) expect(result.data).toEqual({ items: [ { code: 'material_library', name: '素材库', }, ], }) const [, init] = (global.fetch as jest.Mock).mock.calls[0] const headers = init.headers as Record expect(headers.Authorization).toBe('Bearer server-admin-secret') expect(headers['x-yyd-admin-actor']).toBe('super-1') expect(headers['x-yyd-admin-display-name']).toBe('无邪') }) test('创建项目仅保留一次性 project secret/env,不回传 admin secret', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 201, text: async () => JSON.stringify({ code: 'material_library', projectSecret: 'project-secret-1', env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1', adminSecret: 'server-admin-secret', adminKey: 'server-admin-secret', }), }) const result = await createNotificationCenterProject(actor, { code: 'material_library' }) expect(result.status).toBe(201) expect(result.data).toEqual({ code: 'material_library', projectSecret: 'project-secret-1', env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1', }) }) test('轮换密钥同样只保留一次性 project secret/env', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ projectSecret: 'next-secret', envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret', adminToken: 'server-admin-secret', }), }) const result = await rotateNotificationCenterProjectSecret(actor, 'material_library') expect(result.data).toEqual({ projectSecret: 'next-secret', envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret', }) }) test('查看当前密钥命中专属接口,保留 project secret 且剥离 admin secret', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ projectCode: 'script_monitor', secret: 'current-project-secret', env: { YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor', YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret', YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET: 'server-admin-secret', }, project: { recipients: [{ userId: 'ding-private-user' }] }, adminSecret: 'server-admin-secret', }), }) const result = await revealNotificationCenterProjectSecret(actor, 'script_monitor') expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe( 'https://notify-admin.example.com/projects/script_monitor/reveal-secret', ) expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({ method: 'POST', cache: 'no-store', }) expect(result.data).toEqual({ projectCode: 'script_monitor', secret: 'current-project-secret', env: { YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor', YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret', }, }) expect(JSON.stringify(result.data)).not.toContain('server-admin-secret') expect(JSON.stringify(result.data)).not.toContain('ding-private-user') }) test('PATCH/DELETE 项目更新与项目级 recipients/test 会命中嵌套路径', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify({ code: 'material_library', name: '素材库-新版' }), }) .mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify([{ userId: 'ding-1' }]), }) .mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify({ id: 'test-1' }), }) .mockResolvedValueOnce({ ok: true, status: 200, text: async () => JSON.stringify({ deleted: true }), }) await updateNotificationCenterProject(actor, 'material_library', { name: '素材库-新版' }) await listNotificationCenterProjectRecipients( actor, 'material_library', new URLSearchParams('page=2'), ) await sendNotificationCenterProjectTest(actor, 'material_library', { title: '连通性测试', projectCode: 'material_library', }) await deleteNotificationCenterProject(actor, 'material_library') expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe( 'https://notify-admin.example.com/projects/material_library', ) expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({ method: 'PATCH', }) expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe( 'https://notify-admin.example.com/projects/material_library/recipients?page=2', ) expect((global.fetch as jest.Mock).mock.calls[2][0]).toBe( 'https://notify-admin.example.com/projects/material_library/test', ) expect((global.fetch as jest.Mock).mock.calls[2][1]).toMatchObject({ method: 'POST', body: JSON.stringify({ title: '连通性测试' }), }) expect((global.fetch as jest.Mock).mock.calls[3][0]).toBe( 'https://notify-admin.example.com/projects/material_library', ) expect((global.fetch as jest.Mock).mock.calls[3][1]).toMatchObject({ method: 'DELETE', }) }) test('下游 401 会转成中文配置错误且不泄漏管理员密钥', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 401, text: async () => JSON.stringify({ error: 'invalid admin key', adminKey: 'server-admin-secret', }), }) await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({ statusCode: 502, code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED', }) await listNotificationCenterProjects(actor).catch((error: NotificationCenterAdminError) => { expect(error.message).toContain('通知中心管理员鉴权失败') expect(error.message).not.toContain('server-admin-secret') expect(error.details || '').not.toContain('server-admin-secret') }) }) test('409 与 422 会保留业务状态并输出中文错误', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: false, status: 409, text: async () => JSON.stringify({ error: 'project already exists' }), }) .mockResolvedValueOnce({ ok: false, status: 422, text: async () => JSON.stringify({ message: 'code is required' }), }) await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({ statusCode: 409, code: 'NOTIFICATION_CENTER_ADMIN_CONFLICT', }) await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({ statusCode: 422, code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION', }) }) test('下游 500 会转成中文服务异常', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 500, text: async () => JSON.stringify({ error: 'internal error' }), }) await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({ statusCode: 502, code: 'NOTIFICATION_CENTER_ADMIN_UPSTREAM', }) }) test('离线与超时会映射为中文错误', async () => { ;(global.fetch as jest.Mock) .mockRejectedValueOnce(new TypeError('fetch failed')) .mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' })) await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({ statusCode: 503, code: 'NOTIFICATION_CENTER_ADMIN_OFFLINE', }) await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({ statusCode: 504, code: 'NOTIFICATION_CENTER_ADMIN_TIMEOUT', }) }) })