/** @jest-environment node */ import { getServerSession } from 'next-auth' import { isSuperAdmin } from '@/lib/department-permission' import { createNotificationCenterProject, getNotificationCenterProject, listNotificationCenterAudits, listNotificationCenterDeliveries, listNotificationCenterProjectRecipients, listNotificationCenterProjects, rotateNotificationCenterProjectSecret, sendNotificationCenterProjectTest, updateNotificationCenterProject, NotificationCenterAdminError, } from '@/lib/notification-center-admin' import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route' import { GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route' import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route' import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route' import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route' import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route' import { GET as getAuditsRoute } from '@/app/api/admin/notification-center/audits/route' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(), })) jest.mock('@/lib/notification-center-admin', () => ({ NotificationCenterAdminError: class NotificationCenterAdminError extends Error { statusCode: number code: string details?: string | null constructor(message: string, options: { statusCode?: number; code?: string; details?: string | null } = {}) { super(message) this.name = 'NotificationCenterAdminError' this.statusCode = options.statusCode ?? 500 this.code = options.code ?? 'NOTIFICATION_CENTER_ADMIN_ERROR' this.details = options.details ?? null } }, listNotificationCenterProjects: jest.fn(), createNotificationCenterProject: jest.fn(), getNotificationCenterProject: jest.fn(), updateNotificationCenterProject: jest.fn(), rotateNotificationCenterProjectSecret: jest.fn(), listNotificationCenterProjectRecipients: jest.fn(), sendNotificationCenterProjectTest: jest.fn(), listNotificationCenterDeliveries: jest.fn(), listNotificationCenterAudits: jest.fn(), })) describe('notification-center admin routes', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin-1', role: 'SYSTEM_ADMIN', isSuperAdmin: true, username: 'wuxie', realName: '无邪', email: 'wuxie@example.com', }, }) ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) }) test('非真正超管即使 session 带 SYSTEM_ADMIN / isSuperAdmin 也返回 403', async () => { ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any) const body = await response.json() expect(response.status).toBe(403) expect(body.error).toContain('仅超级管理员可访问') expect(listNotificationCenterProjects).not.toHaveBeenCalled() }) test('GET /projects 调用后端代理层并传操作人身份', async () => { ;(listNotificationCenterProjects as jest.Mock).mockResolvedValue({ status: 200, data: [{ code: 'material_library' }], }) const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects?page=1') as any) const body = await response.json() expect(response.status).toBe(200) expect(body.success).toBe(true) expect(listNotificationCenterProjects).toHaveBeenCalledWith( expect.objectContaining({ userId: 'admin-1', username: 'wuxie', realName: '无邪', }), expect.any(URLSearchParams), ) }) test('POST /projects 透传一次性 project secret/env', async () => { ;(createNotificationCenterProject as jest.Mock).mockResolvedValue({ status: 201, data: { code: 'material_library', projectSecret: 'project-secret-1', env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1', }, }) const request = { json: jest.fn().mockResolvedValue({ code: 'material_library' }), } as any const response = await postProjectsRoute(request) const body = await response.json() expect(response.status).toBe(201) expect(body.data.projectSecret).toBe('project-secret-1') expect(body.data.env).toContain('YYD_NOTIFICATION_CENTER_PROJECT_SECRET') }) test('GET /projects/[code] 与 PATCH /projects/[code] 可用', async () => { ;(getNotificationCenterProject as jest.Mock).mockResolvedValue({ status: 200, data: { code: 'material_library', name: '素材库' }, }) ;(updateNotificationCenterProject as jest.Mock).mockResolvedValue({ status: 200, data: { code: 'material_library', name: '素材库-新版' }, }) const getResponse = await getProjectRoute(new Request('http://localhost') as any, { params: { code: 'material_library' } } as any) const patchResponse = await patchProjectRoute( { json: jest.fn().mockResolvedValue({ name: '素材库-新版' }) } as any, { params: { code: 'material_library' } } as any, ) expect(getResponse.status).toBe(200) expect((await getResponse.json()).data.code).toBe('material_library') expect(patchResponse.status).toBe(200) expect((await patchResponse.json()).data.name).toBe('素材库-新版') }) test('POST /projects/[code]/rotate-secret 透传一次性 secret/env', async () => { ;(rotateNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({ status: 200, data: { projectSecret: 'rotated-secret', envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=rotated-secret', }, }) const response = await rotateProjectSecretRoute( { json: jest.fn().mockResolvedValue({ reason: 'rotate' }) } as any, { params: { code: 'material_library' } } as any, ) const body = await response.json() expect(response.status).toBe(200) expect(body.data.projectSecret).toBe('rotated-secret') }) test('GET /projects/[code]/recipients、GET /deliveries、GET /audits、POST /projects/[code]/test 可用', async () => { ;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] }) ;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] }) ;(listNotificationCenterAudits as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'audit-1' }] }) ;(sendNotificationCenterProjectTest as jest.Mock).mockResolvedValue({ status: 200, data: { id: 'test-1' } }) const recipients = await getProjectRecipientsRoute( new Request('http://localhost/api/admin/notification-center/projects/material_library/recipients?page=2') as any, { params: { code: 'material_library' } } as any, ) const deliveries = await getDeliveriesRoute(new Request('http://localhost/api/admin/notification-center/deliveries?projectCode=material_library') as any) const audits = await getAuditsRoute(new Request('http://localhost/api/admin/notification-center/audits?projectCode=material_library') as any) const testResponse = await postProjectTestRoute( { json: jest.fn().mockResolvedValue({ title: '连通性测试' }) } as any, { params: { code: 'material_library' } } as any, ) expect(recipients.status).toBe(200) expect(deliveries.status).toBe(200) expect(audits.status).toBe(200) expect(testResponse.status).toBe(200) expect(listNotificationCenterProjectRecipients).toHaveBeenCalledWith( expect.objectContaining({ userId: 'admin-1' }), 'material_library', expect.any(URLSearchParams), ) expect(sendNotificationCenterProjectTest).toHaveBeenCalledWith( expect.objectContaining({ userId: 'admin-1' }), 'material_library', { title: '连通性测试' }, ) }) test('下游错误会返回中文错误且不泄漏管理员密钥', async () => { const error = new NotificationCenterAdminError('通知中心管理员鉴权失败,请检查服务端密钥配置', { statusCode: 502, code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED', details: 'invalid admin key', }) const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) ;(listNotificationCenterProjects as jest.Mock).mockRejectedValue(error) const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any) const body = await response.json() expect(response.status).toBe(502) expect(body.error).toContain('通知中心管理员鉴权失败') expect(JSON.stringify(body)).not.toContain('server-admin-secret') expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('server-admin-secret')) }) test('未登录返回 401', async () => { ;(getServerSession as jest.Mock).mockResolvedValue(null) const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any) const body = await response.json() expect(response.status).toBe(401) expect(body.error).toBe('未登录') }) })