const dispatcher = require("../../../../src/services/integrations/dispatcher"); const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken"); describe("Integration Dispatcher", () => { it.each(["erp.reimbursement.paid", "erp.journal.export", "erp.procurement.export", "erp.asset.export", "erp.travel.export", "erp.future"])("refuses retired %s without any dispatch or binding write", async (eventType) => { const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); try { await expect(dispatcher.dispatchOutboxEvent({ eventType, payload: {} })).rejects.toMatchObject({ code: "erp_retired", retryable: false }); expect(fetch).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); } }); afterEach(() => { clearWecomAppAccessTokenCache(); vi.restoreAllMocks(); }); it("dispatches notification.im with mock channel", async () => { const result = await dispatcher.dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: "channel-1", channel_type: "wecom", name: "test", enabled: true, config_json: { mode: "mock", routing: {} }, template_json: null, }), }, }, config: { imNotifierEnabled: true }, eventType: "notification.im", payload: { channelId: "channel-1", channelType: "wecom", title: "title", body: "body" }, }); expect(result.channel).toBe("im"); expect(result.code).toBe("mock_sent"); }); it("marks encrypted secret decrypt failure as non-retryable IM dispatch error", async () => { const encryptedSecret = "v1:bad:payload:value"; await expect( dispatcher.dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: "channel-2", channel_type: "wecom", name: "test", enabled: true, config_json: { mode: "wecom_webhook", webhookUrl: "https://example.com/wecom", secretEncrypted: encryptedSecret, timeoutMs: 500, routing: {}, }, template_json: null, }), }, }, config: { imNotifierEnabled: true, fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64"), }, eventType: "notification.im", payload: { channelId: "channel-2", channelType: "wecom", title: "title", body: "body" }, }), ).rejects.toMatchObject({ name: "IntegrationDispatchError", code: "wecom_secret_decrypt_failed", retryable: false, }); }); it("dispatches notification.im app message as no-bound when recipient has no wecom binding", async () => { const prisma = { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: "channel-app", channel_type: "wecom", name: "app-message", enabled: true, config_json: { mode: "wecom_app_message", routing: {} }, template_json: null, }), }, wecomIdentityBinding: { findMany: vi.fn().mockResolvedValue([]), }, }; const result = await dispatcher.dispatchOutboxEvent({ prisma, config: { imNotifierEnabled: true }, eventType: "notification.im", payload: { channelId: "channel-app", channelType: "wecom", title: "title", body: "body", recipientEmployeeIds: ["employee-1"], }, }); expect(result.channel).toBe("im"); expect(result.code).toBe("wecom_app_no_bound_recipients"); expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { employee_id: { in: ["employee-1"] } }, }), ); }); it("dispatches notification.im app message to bound wecom member", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ errcode: 0, access_token: "token-1", expires_in: 7200 }), }) .mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ errcode: 0, errmsg: "ok" }), }); vi.stubGlobal("fetch", fetchMock); const result = await dispatcher.dispatchOutboxEvent({ prisma: { notificationChannel: { findUnique: vi.fn().mockResolvedValue({ id: "channel-app", channel_type: "wecom", name: "app-message", enabled: true, config_json: { mode: "wecom_app_message", timeoutMs: 500, routing: {} }, template_json: null, }), }, wecomIdentityBinding: { findMany: vi.fn().mockResolvedValue([{ external_user_id: "oa_smoke_02" }]), }, }, config: { imNotifierEnabled: true, wecomCorpId: "corp-1", wecomAgentId: "1000002", wecomAgentSecret: "secret-1", }, eventType: "notification.im", payload: { channelId: "channel-app", channelType: "wecom", title: "待办通知", body: "请处理审批", detailUrl: "https://oa.example.com/api/auth/deeplink?token=abc", recipientEmployeeIds: ["employee-1"], }, }); expect(result.channel).toBe("im"); expect(result.code).toBe("wecom_app_sent"); expect(fetchMock).toHaveBeenCalledTimes(2); const sendBody = JSON.parse(fetchMock.mock.calls[1][1].body); expect(sendBody).toEqual( expect.objectContaining({ touser: "oa_smoke_02", msgtype: "textcard", agentid: 1000002, textcard: expect.objectContaining({ title: "待办通知", url: "https://oa.example.com/api/auth/deeplink?token=abc", }), }), ); expect(String(fetchMock.mock.calls[1][0])).toContain("/cgi-bin/message/send"); expect(JSON.stringify(result.response)).not.toContain("token-1"); }); it("throws error for unsupported event", async () => { await expect( dispatcher.dispatchOutboxEvent({ config: {}, eventType: "unknown.event", }), ).rejects.toThrow("unsupported_outbox_event:unknown.event"); }); });