const { IntegrationDispatchError, dispatchOutboxEvent, } = require('../../src/services/integrations/dispatcher'); describe('dispatchOutboxEvent', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; vi.restoreAllMocks(); }); it('handles mock ERP success', async () => { const result = await dispatchOutboxEvent({ config: { erpSyncEnabled: true, erpSyncProvider: 'mock', }, eventType: 'erp.reimbursement.paid', payload: { reimbursementId: 'rb-1', formNumber: 'BX-001', }, }); expect(result.channel).toBe('erp'); expect(result.code).toBe('mock_synced'); expect(result.response.externalId).toContain('BX-001'); }); it('records integration binding for reimbursement journal export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), }, }; const result = await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock', }, eventType: 'erp.journal.export', payload: { journalType: 'reimbursement_paid', entityType: 'reimbursement_form', entityId: 'rb-1', documentNo: 'BX-20260308-0001', }, }); expect(result.channel).toBe('erp'); expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'reimbursement_journal', local_id: 'rb-1', }, }, }), ); }); it('records commitment binding for procurement journal export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), }, }; await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, eventType: 'erp.journal.export', payload: { journalType: 'purchase_request_commitment', entityType: 'purchase_request', entityId: 'pr-1', documentNo: 'PR-001', references: { purchaseRequestId: 'pr-1', requestNo: 'PR-001' }, }, }); expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'purchase_request_commitment_journal', local_id: 'pr-1', }, }, }), ); }); it('records accrual binding for procurement journal export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), }, }; await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, eventType: 'erp.journal.export', payload: { journalType: 'purchase_order_accrual', entityType: 'purchase_order_archive', entityId: 'archive-1', documentNo: 'PO-001', references: { purchaseOrderArchiveId: 'archive-1', poNo: 'PO-001' }, }, }); expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'purchase_order_accrual_journal', local_id: 'archive-1', }, }, }), ); }); it('records PO and contract bindings for procurement summary export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-1' }), }, }; await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, eventType: 'erp.procurement.export', payload: { exportType: 'purchase_order_archive', entityType: 'purchase_order_archive', entityId: 'archive-1', poNo: 'PO-001', contractNo: 'CT-001', }, }); expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'purchase_order_po', local_id: 'archive-1', }, }, }), ); expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'purchase_order_contract', local_id: 'archive-1', }, }, }), ); }); it('records asset binding for asset export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-asset-1' }), }, }; await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, eventType: 'erp.asset.export', payload: { exportType: 'asset_ledger_summary', entityType: 'asset_card', entityId: 'asset-1', assetNo: 'ZC-001', }, }); expect(prisma.integrationBinding.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'asset_erp_card', local_id: 'asset-1', }, }, }), ); }); it('records summary and settlement bindings for travel reconciliation export', async () => { const prisma = { integrationBinding: { upsert: vi.fn().mockResolvedValue({ id: 'binding-travel-1' }), }, }; await dispatchOutboxEvent({ prisma, config: { erpSyncEnabled: true, erpSyncProvider: 'mock' }, eventType: 'erp.travel.export', payload: { exportType: 'travel_reconciliation_summary', entityType: 'travel_reconciliation_batch', entityId: 'batch-1', batchNo: 'TRB-20260311-0001', }, }); expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'travel_payable_summary', local_id: 'batch-1', }, }, }), ); expect(prisma.integrationBinding.upsert).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: { provider_entity_type_local_id: { provider: 'erp', entity_type: 'travel_settlement_batch', local_id: 'batch-1', }, }, }), ); }); 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'); }); });