326 行
8.5 KiB
JavaScript
326 行
8.5 KiB
JavaScript
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 standard 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: {
|
|
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("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);
|
|
});
|
|
});
|