const { IntegrationDispatchError, dispatchOutboxEvent, } = require('../../src/services/integrations/dispatcher'); describe('dispatchOutboxEvent', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('loads IM channel and dispatches mock wecom message', async () => { const prisma = { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-1', channel_type: 'wecom', name: '采购经理群', enabled: true, config_json: { mode: 'mock', routing: {}, }, template_json: { title: '{{title}}', body: '{{body}}', footer: '来自海音达工作台', deepLinkLabel: '查看详情', }, }), }, }; const result = await dispatchOutboxEvent({ prisma, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'channel-1', channelType: 'wecom', notificationType: 'workflow.task_assigned', title: '【待办】采购申请待审批', body: '采购申请已流转到你,请及时处理。', }, }); expect(prisma.notificationChannel.findUnique).toHaveBeenCalledWith({ where: { id: 'channel-1' }, select: { id: true, channel_type: true, name: true, enabled: true, config_json: true, template_json: true, }, }); expect(result.channel).toBe('im'); expect(result.code).toBe('mock_sent'); expect(result.previewMessage).toContain('采购申请待审批'); }); it('safely skips missing IM channel', async () => { const result = await dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue(null), }, }, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'missing-channel', channelType: 'wecom', }, }); expect(result.channel).toBe('im'); expect(result.code).toBe('channel_not_found'); expect(result.skipped).toBe(true); }); it('safely skips disabled IM channel', async () => { const result = await dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-2', channel_type: 'wecom', name: 'disabled', enabled: false, config_json: { mode: 'mock', routing: {} }, template_json: null, }), }, }, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'channel-2', channelType: 'wecom', }, }); expect(result.code).toBe('channel_disabled'); expect(result.skipped).toBe(true); }); it('maps wecom 4xx as non-retryable IM dispatch error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 400, json: vi.fn().mockResolvedValue({ errcode: 40013, errmsg: 'bad request' }), headers: { get: () => 'application/json' }, }); await expect( dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-3', channel_type: 'wecom', name: 'wecom-4xx', enabled: true, config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', timeoutMs: 500, routing: {}, }, template_json: null, }), }, }, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'channel-3', channelType: 'wecom', title: 'test', body: 'body', }, }), ).rejects.toMatchObject({ name: 'IntegrationDispatchError', code: 'wecom_http_4xx', retryable: false, }); }); it('maps wecom 5xx as retryable IM dispatch error', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503, json: vi.fn().mockResolvedValue({ errcode: 500, errmsg: 'server error' }), headers: { get: () => 'application/json' }, }); await expect( dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-4', channel_type: 'wecom', name: 'wecom-5xx', enabled: true, config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', timeoutMs: 500, routing: {}, }, template_json: null, }), }, }, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'channel-4', channelType: 'wecom', title: 'test', body: 'body', }, }), ).rejects.toMatchObject({ name: 'IntegrationDispatchError', code: 'wecom_http_5xx', retryable: true, }); }); it('maps wecom timeout as retryable IM dispatch error', async () => { global.fetch = vi.fn().mockRejectedValue({ name: 'AbortError' }); await expect( dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-5', channel_type: 'wecom', name: 'wecom-timeout', enabled: true, config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', timeoutMs: 500, routing: {}, }, template_json: null, }), }, }, config: { imNotifierEnabled: true, }, eventType: 'notification.im', payload: { channelId: 'channel-5', channelType: 'wecom', title: 'test', body: 'body', }, }), ).rejects.toMatchObject({ name: 'IntegrationDispatchError', code: 'wecom_timeout', retryable: true, }); }); it('maps encrypted secret decrypt failure as non-retryable IM dispatch error', async () => { await expect( dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: 'channel-6', channel_type: 'wecom', name: 'wecom-secret-bad', enabled: true, config_json: { mode: 'wecom_webhook', webhookUrl: 'https://example.com/wecom', secretEncrypted: 'v1:bad:payload:value', timeoutMs: 500, routing: {}, }, template_json: null, }), }, }, config: { imNotifierEnabled: true, fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64'), }, eventType: 'notification.im', payload: { channelId: 'channel-6', channelType: 'wecom', title: 'test', body: 'body', }, }), ).rejects.toMatchObject({ name: 'IntegrationDispatchError', code: 'wecom_secret_decrypt_failed', retryable: false, }); }); it('throws error for unsupported event', async () => { await expect( dispatchOutboxEvent({ config: {}, eventType: 'unknown.event', }), ).rejects.toThrow('unsupported_outbox_event:unknown.event'); }); });