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("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" }), }); 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" }), }); 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("throws non-retryable error for unsupported event", async () => { await expect( dispatchOutboxEvent({ config: { erpSyncEnabled: true, erpSyncProvider: "mock" }, eventType: "unsupported.event", payload: {}, }), ).rejects.toMatchObject({ name: "IntegrationDispatchError", code: "unsupported_outbox_event", retryable: false, }); }); it("preserves error class semantics", () => { const error = new IntegrationDispatchError({ code: "unit_test", message: "unit test", retryable: true, statusCode: 503, }); expect(error).toBeInstanceOf(Error); expect(error.code).toBe("unit_test"); expect(error.retryable).toBe(true); expect(error.statusCode).toBe(503); }); });