const { dispatchNotificationBatch } = require('../../../src/services/notificationDispatcher'); function buildChannel(overrides = {}) { return { id: 'channel-1', channel_type: 'wecom', name: '审批通知群', enabled: true, config_json: { mode: 'mock', webhookUrl: null, timeoutMs: 5000, routing: { roleCodes: [], domainScopes: [], allowedTypes: [], }, }, template_json: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, ...overrides, }; } function createPrismaState({ employees = [], channels = [] } = {}) { const deliveryState = []; return { prisma: { employee: { findMany: vi.fn().mockImplementation(async ({ where }) => { const ids = new Set(where?.id?.in ?? []); return employees .filter((item) => ids.has(item.id)) .filter((item) => item.status === 'active') .filter((item) => item.notification_push_enabled !== false) .map((item) => ({ id: item.id, roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })), })); }), }, notificationChannel: { findMany: vi.fn().mockResolvedValue(channels), }, notificationDelivery: { create: vi.fn().mockImplementation(async ({ data }) => { const row = { id: `delivery-${deliveryState.length + 1}`, ...data, }; deliveryState.push(row); return row; }), update: vi.fn().mockImplementation(async ({ where, data }) => { const current = deliveryState.find((item) => item.id === where.id); Object.assign(current, data); return current; }), }, }, deliveryState, }; } describe('notificationDispatcher', () => { afterEach(() => { vi.clearAllMocks(); }); it('dispatches a mock channel and persists a success attempt', async () => { const { prisma, deliveryState } = createPrismaState({ employees: [ { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, ], channels: [buildChannel()], }); const sendMessage = vi.fn().mockResolvedValue({ ok: true, retryable: false, code: 'mock_sent', message: null, response: { mode: 'mock' }, }); const result = await dispatchNotificationBatch({ prisma, config: { imNotifierEnabled: true, appOrigin: 'https://oa.example.com', jwtSecret: 'test-secret', }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, requestId: 'req-1', notifications: [ { id: 'notification-1', recipient_employee_id: 'manager-1', target_type: 'workflow_task', }, ], type: 'workflow.task_assigned', title: '测试待办', body: '请处理审批', targetType: 'workflow_task', targetId: 'task-1', targetPath: '/workflow/tasks/task-1', sendMessage, retryDelayMs: 0, }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(deliveryState).toHaveLength(1); expect(deliveryState[0]).toEqual( expect.objectContaining({ notification_id: 'notification-1', channel_id: 'channel-1', status: 'success', result_code: 'mock_sent', }), ); expect(result).toEqual( expect.objectContaining({ matchedChannelCount: 1, eligibleRecipientCount: 1, deliveredChannels: [ expect.objectContaining({ channelId: 'channel-1', status: 'success', }), ], }), ); }); it('retries retryable failures until a later success', async () => { const { prisma, deliveryState } = createPrismaState({ employees: [ { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, ], channels: [buildChannel()], }); const sendMessage = vi .fn() .mockResolvedValueOnce({ ok: false, retryable: true, code: 'wecom_timeout', message: 'timeout', response: null, }) .mockResolvedValueOnce({ ok: false, retryable: true, code: 'wecom_http_429', message: 'rate limited', response: null, }) .mockResolvedValueOnce({ ok: true, retryable: false, code: 'wecom_sent', message: null, response: { errcode: 0 }, }); const result = await dispatchNotificationBatch({ prisma, config: { imNotifierEnabled: true, appOrigin: 'https://oa.example.com', jwtSecret: 'test-secret', }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, requestId: 'req-2', notifications: [ { id: 'notification-1', recipient_employee_id: 'manager-1', target_type: 'workflow_task', }, ], type: 'workflow.task_assigned', title: '测试待办', body: '请处理审批', targetType: 'workflow_task', targetId: 'task-1', sendMessage, retryDelayMs: 0, }); expect(sendMessage).toHaveBeenCalledTimes(3); expect(deliveryState).toHaveLength(3); expect(deliveryState.map((item) => item.status)).toEqual(['failed', 'failed', 'success']); expect(result.deliveredChannels[0]).toEqual( expect.objectContaining({ status: 'success', attemptNo: 3, resultCode: 'wecom_sent', }), ); }); it('does not retry when provider marks the failure as non-retryable', async () => { const { prisma, deliveryState } = createPrismaState({ employees: [ { id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] }, ], channels: [buildChannel()], }); const sendMessage = vi.fn().mockResolvedValue({ ok: false, retryable: false, code: 'wecom_api_error', message: 'invalid webhook', response: { errcode: 93000 }, }); const result = await dispatchNotificationBatch({ prisma, config: { imNotifierEnabled: true, appOrigin: 'https://oa.example.com', jwtSecret: 'test-secret', }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, requestId: 'req-3', notifications: [ { id: 'notification-1', recipient_employee_id: 'manager-1', target_type: 'workflow_task', }, ], type: 'workflow.task_assigned', title: '测试待办', body: '请处理审批', targetType: 'workflow_task', targetId: 'task-1', sendMessage, retryDelayMs: 0, }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(deliveryState).toHaveLength(1); expect(result.deliveredChannels[0]).toEqual( expect.objectContaining({ status: 'failed', attemptNo: 1, resultCode: 'wecom_api_error', }), ); }); it('skips dispatch when all recipients opted out of external push', async () => { const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const { prisma, deliveryState } = createPrismaState({ employees: [ { id: 'manager-1', status: 'active', notification_push_enabled: false, roles: ['manager'] }, ], channels: [buildChannel()], }); const sendMessage = vi.fn(); const result = await dispatchNotificationBatch({ prisma, config: { imNotifierEnabled: true, appOrigin: 'https://oa.example.com', jwtSecret: 'test-secret', }, logger, requestId: 'req-4', notifications: [ { id: 'notification-1', recipient_employee_id: 'manager-1', target_type: 'workflow_task', }, ], type: 'workflow.task_assigned', title: '测试待办', body: '请处理审批', targetType: 'workflow_task', targetId: 'task-1', sendMessage, retryDelayMs: 0, }); expect(sendMessage).not.toHaveBeenCalled(); expect(deliveryState).toHaveLength(0); expect(result).toEqual( expect.objectContaining({ skippedReason: 'no_eligible_recipients', }), ); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ notificationType: 'workflow.task_assigned', skippedReason: 'no_eligible_recipients', }), 'Notification dispatch skipped', ); }); it('skips when no channel matches recipient roles and notification scope', async () => { const { prisma, deliveryState } = createPrismaState({ employees: [ { id: 'employee-1', status: 'active', notification_push_enabled: true, roles: ['employee'] }, ], channels: [ buildChannel({ config_json: { mode: 'mock', webhookUrl: null, timeoutMs: 5000, routing: { roleCodes: ['manager'], domainScopes: ['workflow'], allowedTypes: ['workflow.task_assigned'], }, }, }), ], }); const result = await dispatchNotificationBatch({ prisma, config: { imNotifierEnabled: true, appOrigin: 'https://oa.example.com', jwtSecret: 'test-secret', }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, requestId: 'req-5', notifications: [ { id: 'notification-1', recipient_employee_id: 'employee-1', target_type: 'workflow_task', }, ], type: 'workflow.task_assigned', title: '测试待办', body: '请处理审批', targetType: 'workflow_task', targetId: 'task-1', retryDelayMs: 0, }); expect(deliveryState).toHaveLength(0); expect(result).toEqual( expect.objectContaining({ skippedReason: 'no_matching_channel', eligibleRecipientCount: 1, }), ); }); });