const Fastify = require('fastify'); const { AppError } = require('../../../src/utils/AppError'); const { NOTIFICATION_TYPES } = require('../../../src/constants/notificationTypes'); const { encryptStringOrThrow } = require('../../../src/services/crypto'); const notificationsRoutes = require('../../../src/routes/notifications'); function buildChannelRecord(overrides = {}) { const now = new Date('2026-03-13T00:00:00.000Z'); return { id: '11111111-1111-4111-8111-111111111111', channel_type: 'wecom', name: '采购经理群', enabled: true, config_json: { mode: 'mock', webhookUrl: null, timeoutMs: 5000, routing: {}, }, template_json: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, created_at: now, created_by: 'employee-1', updated_at: now, updated_by: 'employee-1', ...overrides, }; } function buildNotificationRecord(overrides = {}) { const now = new Date('2026-03-13T09:00:00.000Z'); return { id: '22222222-2222-4222-8222-222222222222', recipient_employee_id: 'employee-2', type: 'workflow.task_assigned', title: '【待办】采购申请待审批', body: '请及时处理审批任务。', target_type: 'workflow_task', target_id: '33333333-3333-4333-8333-333333333333', read_at: null, created_at: now, ...overrides, }; } function buildDeliveryRecord(overrides = {}) { const notification = overrides.notification ?? buildNotificationRecord(); const channel = overrides.channel ?? buildChannelRecord(); const now = new Date('2026-03-13T10:00:00.000Z'); return { id: '44444444-4444-4444-8444-444444444444', notification_id: notification.id, channel_id: channel.id, status: 'success', attempt_no: 1, result_code: 'mock_sent', error_message: null, response_body: { mode: 'mock' }, attempted_at: now, notification, channel, ...overrides, }; } function applySelect(record, select) { if (!select) { return { ...record }; } return Object.fromEntries( Object.entries(select) .filter(([, enabled]) => Boolean(enabled)) .map(([key]) => [key, record[key]]), ); } function matchNotificationWhere(item, where = {}) { if (where.recipient_employee_id && item.recipient_employee_id !== where.recipient_employee_id) { return false; } if (Object.prototype.hasOwnProperty.call(where, 'read_at') && where.read_at === null && item.read_at !== null) { return false; } const typeFilter = where.type?.in ?? null; if (Array.isArray(typeFilter) && typeFilter.length > 0 && !typeFilter.includes(item.type)) { return false; } return true; } function createPrismaState({ channels = [], deliveries = [], notifications = [] } = {}) { const stateChannels = channels.map((channel) => ({ ...channel })); const stateDeliveries = deliveries.map((delivery) => ({ ...delivery })); const stateNotifications = notifications.map((notification) => ({ ...notification })); const auditEntries = []; const notificationChannel = { findMany: vi.fn().mockImplementation(async () => [...stateChannels].sort((left, right) => { const typeOrder = String(left.channel_type).localeCompare(String(right.channel_type)); if (typeOrder !== 0) { return typeOrder; } return String(left.name).localeCompare(String(right.name)); }), ), findUnique: vi.fn().mockImplementation(async ({ where }) => { if (where?.id) { return stateChannels.find((item) => item.id === where.id) ?? null; } if (where?.channel_type_name) { return ( stateChannels.find( (item) => item.channel_type === where.channel_type_name.channel_type && item.name === where.channel_type_name.name, ) ?? null ); } return null; }), create: vi.fn().mockImplementation(async ({ data }) => { const duplicated = stateChannels.find( (item) => item.channel_type === data.channel_type && item.name === data.name, ); if (duplicated) { const error = new Error('duplicate_channel_name'); error.code = 'P2002'; throw error; } const now = new Date('2026-03-13T10:00:00.000Z'); const channel = buildChannelRecord({ id: `00000000-0000-4000-8000-${String(stateChannels.length + 1).padStart(12, '0')}`, channel_type: data.channel_type, name: data.name, enabled: data.enabled, config_json: data.config_json, template_json: data.template_json, created_at: now, updated_at: now, created_by: data.created_by, updated_by: data.updated_by, }); stateChannels.push(channel); return channel; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const index = stateChannels.findIndex((item) => item.id === where.id); if (index < 0) { return null; } const current = stateChannels[index]; const next = { ...current, ...data, config_json: data.config_json ?? current.config_json, template_json: data.template_json ?? current.template_json, updated_at: new Date('2026-03-13T11:00:00.000Z'), }; stateChannels[index] = next; return next; }), upsert: vi.fn().mockImplementation(async ({ where, create, update }) => { const current = await notificationChannel.findUnique({ where }); if (current) { return notificationChannel.update({ where: { id: current.id }, data: update }); } return notificationChannel.create({ data: create }); }), }; const notification = { findMany: vi.fn().mockImplementation(async ({ where, skip, take, select }) => { const items = stateNotifications .filter((item) => matchNotificationWhere(item, where)) .sort((left, right) => right.created_at.getTime() - left.created_at.getTime()) .slice(skip ?? 0, (skip ?? 0) + (take ?? stateNotifications.length)) .map((item) => applySelect(item, select)); return items; }), count: vi.fn().mockImplementation(async ({ where }) => stateNotifications.filter((item) => matchNotificationWhere(item, where)).length), updateMany: vi.fn().mockImplementation(async ({ where, data }) => { let count = 0; stateNotifications.forEach((item, index) => { if (!matchNotificationWhere(item, where)) { return; } stateNotifications[index] = { ...item, ...data, }; count += 1; }); return { count }; }), findUnique: vi.fn().mockImplementation(async ({ where, select }) => { const item = stateNotifications.find((notificationItem) => notificationItem.id === where.id); return item ? applySelect(item, select) : null; }), update: vi.fn().mockImplementation(async ({ where, data, select }) => { const index = stateNotifications.findIndex((item) => item.id === where.id); if (index < 0) { return null; } stateNotifications[index] = { ...stateNotifications[index], ...data, }; return applySelect(stateNotifications[index], select); }), }; const transactionClient = { notification, $queryRaw: vi.fn().mockImplementation(async (_strings, notificationId) => { return stateNotifications.some((item) => item.id === notificationId) ? [{ id: notificationId }] : []; }), }; return { prisma: { notification, notificationChannel, notificationDelivery: { findMany: vi.fn().mockImplementation(async ({ where, take }) => { let items = [...stateDeliveries]; if (where?.notification_id) { items = items.filter((item) => item.notification_id === where.notification_id); } if (where?.channel_id) { items = items.filter((item) => item.channel_id === where.channel_id); } items.sort((left, right) => { const timeDiff = right.attempted_at.getTime() - left.attempted_at.getTime(); if (timeDiff !== 0) { return timeDiff; } return Number(right.attempt_no ?? 0) - Number(left.attempt_no ?? 0); }); return items.slice(0, take ?? items.length); }), }, auditLog: { create: vi.fn().mockImplementation(async ({ data }) => { auditEntries.push(data); return { id: `audit-${auditEntries.length}` }; }), }, wecomIdentityBinding: { findMany: vi.fn().mockResolvedValue([]), }, $transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)), }, stateChannels, stateNotifications, auditEntries, }; } async function buildApp({ prisma, config = {}, permissionCodes = ['notification.channel.manage'] }) { const app = Fastify(); app.decorate('prisma', prisma); app.decorate('config', { fieldEncryptionKey: '', imNotifierEnabled: true, ...config, }); app.decorate('authenticate', async (request) => { if (!request.headers.authorization) { throw new AppError({ code: 'AUTH_001', message: '未登录', statusCode: 401, }); } request.employee = { id: 'employee-1', name: '测试人', permissionCodes, }; }); app.decorate('hasPermission', (employee, permissionCode) => (employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode), ); app.decorate('requirePermission', (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: 'PERM_001', message: '无权限', statusCode: 403, }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: 'SYS_001', message: '服务器内部错误', details: {}, }, }); }); await app.register(notificationsRoutes, { prefix: '/api' }); return app; } describe('notifications channel routes', () => { const validFieldEncryptionKey = Buffer.alloc(32, 7).toString('base64'); afterEach(() => { vi.clearAllMocks(); }); it('lists channels with masked legacy secret only', async () => { const { prisma } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'legacy-secret', timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'GET', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0].configJson).toEqual( expect.objectContaining({ secretConfigured: true, secretMasked: expect.any(String), }), ); expect(response.json().data.items[0].configJson.secret).toBeUndefined(); await app.close(); }); it('creates wecom webhook channel with encrypted secret and masked read model', async () => { const { prisma, stateChannels } = createPrismaState(); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, payload: { channelType: 'wecom', name: '企微经理群', enabled: true, configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'super-secret', timeoutMs: 800, routing: { roleCodes: ['manager'], domainScopes: ['workflow'], allowedTypes: ['workflow.task_assigned'], }, }, templateJson: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, }, }); expect(response.statusCode).toBe(200); expect(response.json().data.channel.configJson).toEqual( expect.objectContaining({ secretConfigured: true, secretMasked: expect.any(String), }), ); expect(response.json().data.channel.configJson.secret).toBeUndefined(); expect(stateChannels[0].config_json.secretEncrypted).toMatch(/^v1:/); expect(stateChannels[0].config_json.secret).toBeUndefined(); await app.close(); }); it('creates wecom app message channel without webhook or secret', async () => { const { prisma, stateChannels } = createPrismaState(); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, payload: { channelType: 'wecom', name: '企微个人应用消息', enabled: true, configJson: { mode: 'wecom_app_message', timeoutMs: 800, routing: { roleCodes: ['manager'], domainScopes: ['workflow', 'leave', 'procurement', 'travel'], allowedTypes: [ NOTIFICATION_TYPES.workflowTaskAssigned, NOTIFICATION_TYPES.leaveReturned, NOTIFICATION_TYPES.purchaseRequestReturned, NOTIFICATION_TYPES.travelRequestReturned, ], }, }, }, }); expect(response.statusCode).toBe(200); expect(response.json().data.channel.configJson).toEqual( expect.objectContaining({ mode: 'wecom_app_message', webhookUrl: null, secretConfigured: false, secretMasked: null, routing: { roleCodes: ['manager'], domainScopes: ['workflow', 'leave', 'procurement', 'travel'], allowedTypes: [ NOTIFICATION_TYPES.workflowTaskAssigned, NOTIFICATION_TYPES.leaveReturned, NOTIFICATION_TYPES.purchaseRequestReturned, NOTIFICATION_TYPES.travelRequestReturned, ], }, }), ); expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined(); expect(stateChannels[0].config_json.webhookUrl).toBeNull(); await app.close(); }); it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => { const { prisma } = createPrismaState(); const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer test-token' }, payload: { channelType: 'wecom', name: '企微经理群', enabled: true, configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secret: 'super-secret', timeoutMs: 800, routing: {}, }, }, }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe('VALID_001'); expect(response.json().error.details).toEqual(expect.objectContaining({ secret: 'field_encryption_key_missing' })); await app.close(); }); it('patches channel without replacing existing encrypted secret when secret omitted', async () => { const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret'); const { prisma, stateChannels } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'PATCH', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111', headers: { authorization: 'Bearer test-token' }, payload: { name: '企微经理群-更新', configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom-next', timeoutMs: 900, routing: { roleCodes: ['manager'], domainScopes: ['workflow'], allowedTypes: [], }, }, }, }); expect(response.statusCode).toBe(200); expect(stateChannels[0].config_json.secretEncrypted).toBe(encryptedSecret); expect(response.json().data.channel.configJson.secretConfigured).toBe(true); expect(response.json().data.channel.configJson.secret).toBeUndefined(); await app.close(); }); it('clears secret on patch when clearSecret is true', async () => { const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret'); const { prisma, stateChannels } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'PATCH', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111', headers: { authorization: 'Bearer test-token' }, payload: { configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', clearSecret: true, timeoutMs: 800, routing: {}, }, }, }); expect(response.statusCode).toBe(200); expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined(); expect(stateChannels[0].config_json.secret).toBeUndefined(); expect(response.json().data.channel.configJson.secretConfigured).toBe(false); expect(response.json().data.channel.configJson.secretMasked).toBeNull(); await app.close(); }); it('returns message and retryable in test result without exposing plain secret', async () => { const encryptedSecret = 'v1:bad:payload:value'; const { prisma } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: encryptedSecret, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma, config: { fieldEncryptionKey: validFieldEncryptionKey }, }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test', headers: { authorization: 'Bearer test-token' }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.channel.configJson.secret).toBeUndefined(); expect(response.json().data.testResult).toEqual( expect.objectContaining({ ok: false, code: 'wecom_secret_decrypt_failed', message: 'Failed to decrypt notification channel secret', retryable: false, }), ); await app.close(); }); it('tests wecom app message channel against current bound admin when available', async () => { const { prisma } = createPrismaState({ channels: [ buildChannelRecord({ config_json: { mode: 'wecom_app_message', webhookUrl: null, timeoutMs: 800, routing: {}, }, }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'POST', url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test', headers: { authorization: 'Bearer test-token' }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.testResult).toEqual( expect.objectContaining({ ok: true, code: 'wecom_app_no_bound_recipients', }), ); expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { employee_id: { in: ['employee-1'] } }, }), ); await app.close(); }); it('lists grouped delivery summaries with latest status and attempt count', async () => { const notification = buildNotificationRecord({ id: '55555555-5555-4555-8555-555555555555', title: '【待办】采购申请 CG-001 待审批', }); const channel = buildChannelRecord(); const { prisma } = createPrismaState({ channels: [channel], deliveries: [ buildDeliveryRecord({ id: 'delivery-2', notification, channel, notification_id: notification.id, channel_id: channel.id, status: 'failed', attempt_no: 2, result_code: 'wecom_timeout', error_message: 'timeout', attempted_at: new Date('2026-03-13T10:02:00.000Z'), }), buildDeliveryRecord({ id: 'delivery-1', notification, channel, notification_id: notification.id, channel_id: channel.id, status: 'failed', attempt_no: 1, result_code: 'wecom_http_429', error_message: 'rate limit', attempted_at: new Date('2026-03-13T10:01:00.000Z'), }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'GET', url: '/api/notifications/deliveries?status=failed', headers: { authorization: 'Bearer test-token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.items).toEqual([ expect.objectContaining({ notificationId: notification.id, channelId: channel.id, channelName: channel.name, notificationTitle: notification.title, status: 'failed', attemptCount: 2, resultCode: 'wecom_timeout', }), ]); await app.close(); }); it('rejects delivery list without notification.channel.manage permission', async () => { const { prisma } = createPrismaState(); const app = await buildApp({ prisma, permissionCodes: [] }); const response = await app.inject({ method: 'GET', url: '/api/notifications/deliveries', headers: { authorization: 'Bearer test-token' }, }); expect(response.statusCode).toBe(403); expect(response.json().error.code).toBe('PERM_001'); await app.close(); }); }); describe('notification inbox routes', () => { afterEach(() => { vi.clearAllMocks(); }); it('filters notifications by type while keeping unreadCount global', async () => { const { prisma } = createPrismaState({ notifications: [ buildNotificationRecord({ id: '10000000-0000-4000-8000-000000000001', recipient_employee_id: 'employee-1', type: 'leave.approved', created_at: new Date('2026-03-13T11:00:00.000Z'), }), buildNotificationRecord({ id: '10000000-0000-4000-8000-000000000002', recipient_employee_id: 'employee-1', type: 'reimbursement.approved', created_at: new Date('2026-03-13T10:00:00.000Z'), }), buildNotificationRecord({ id: '10000000-0000-4000-8000-000000000003', recipient_employee_id: 'employee-1', type: 'leave.approved', read_at: new Date('2026-03-13T09:30:00.000Z'), created_at: new Date('2026-03-13T09:00:00.000Z'), }), buildNotificationRecord({ id: '10000000-0000-4000-8000-000000000004', recipient_employee_id: 'employee-9', type: 'leave.approved', created_at: new Date('2026-03-13T08:00:00.000Z'), }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'GET', url: '/api/notifications?type=leave.approved', headers: { authorization: 'Bearer test-token' }, }); expect(response.statusCode).toBe(200); expect(response.json().data.total).toBe(2); expect(response.json().data.unreadCount).toBe(2); expect(response.json().data.items).toEqual([ expect.objectContaining({ id: '10000000-0000-4000-8000-000000000001', type: 'leave.approved', }), expect.objectContaining({ id: '10000000-0000-4000-8000-000000000003', type: 'leave.approved', }), ]); await app.close(); }); it('marks all unread notifications as read for current employee', async () => { const { prisma, stateNotifications } = createPrismaState({ notifications: [ buildNotificationRecord({ id: '20000000-0000-4000-8000-000000000001', recipient_employee_id: 'employee-1', read_at: null, }), buildNotificationRecord({ id: '20000000-0000-4000-8000-000000000002', recipient_employee_id: 'employee-1', type: 'leave.approved', read_at: null, created_at: new Date('2026-03-13T08:00:00.000Z'), }), buildNotificationRecord({ id: '20000000-0000-4000-8000-000000000003', recipient_employee_id: 'employee-1', read_at: new Date('2026-03-13T07:00:00.000Z'), }), buildNotificationRecord({ id: '20000000-0000-4000-8000-000000000004', recipient_employee_id: 'employee-2', read_at: null, }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'POST', url: '/api/notifications/mark-all-read', headers: { authorization: 'Bearer test-token' }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ updatedCount: 2 }); expect(stateNotifications.filter((item) => item.recipient_employee_id === 'employee-1' && item.read_at === null)).toHaveLength(0); expect( stateNotifications.find((item) => item.id === '20000000-0000-4000-8000-000000000004')?.read_at, ).toBeNull(); await app.close(); }); it('keeps mark-all-read idempotent when there are no unread notifications', async () => { const { prisma } = createPrismaState({ notifications: [ buildNotificationRecord({ id: '30000000-0000-4000-8000-000000000001', recipient_employee_id: 'employee-1', read_at: new Date('2026-03-13T07:00:00.000Z'), }), ], }); const app = await buildApp({ prisma }); const response = await app.inject({ method: 'POST', url: '/api/notifications/mark-all-read', headers: { authorization: 'Bearer test-token' }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ updatedCount: 0 }); await app.close(); }); }); describe('notification business error reasons', () => { it.each([ ['GET', '/notifications?page=0', undefined, 'notification.list_query_invalid', 'page'], ['GET', '/notifications/deliveries?limit=0', undefined, 'notification.delivery_query_invalid', 'limit'], ['PATCH', '/notifications/channels/not-an-id', { name: '合成渠道' }, 'notification.id_invalid', null], ['POST', '/notifications/channels', { channelType: 'wecom', name: '' }, 'notification.channel_input_invalid', 'name'], ])('keeps %s %s errors precise and side-effect-free', async (method, url, payload, reasonCode, field) => { const { prisma, auditEntries, stateChannels } = createPrismaState(); const app = await buildApp({ prisma }); try { const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error.code).toBe('VALID_001'); expect(response.json().error.details.reasonCode).toBe(reasonCode); if (field) expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); else expect(response.json().error.details.id).toBe('invalid_uuid'); expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); expect(prisma.notification.updateMany).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.notificationDelivery.findMany).not.toHaveBeenCalled(); expect(stateChannels).toEqual([]); expect(auditEntries).toEqual([]); } finally { await app.close(); } }); it('keeps duplicate channel conflict and has no successful audit', async () => { const channel = buildChannelRecord({ name: '合成重复渠道' }); const { prisma, stateChannels, auditEntries } = createPrismaState({ channels: [channel] }); const app = await buildApp({ prisma }); try { const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: channel.name, configJson: { mode: 'mock' } } }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ reasonCode: 'notification.channel_name_duplicate', name: 'duplicate_channel_name' }); expect(stateChannels).toHaveLength(1); expect(auditEntries).toEqual([]); } finally { await app.close(); } }); it('reports missing encryption capability without exposing the submitted synthetic secret', async () => { const { prisma, auditEntries } = createPrismaState(); const app = await buildApp({ prisma }); const secret = 'synthetic-do-not-echo'; try { const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: '合成渠道', configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.invalid/synthetic', secret } } }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ reasonCode: 'notification.encryption_key_unavailable', secret: 'field_encryption_key_missing' }); expect(response.body.includes(secret)).toBe(false); expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); expect(auditEntries).toEqual([]); } finally { await app.close(); } }); });