feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发
这个提交包含在:
@@ -4,6 +4,13 @@ const {
|
||||
} = 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: {
|
||||
@@ -22,58 +29,235 @@ describe("dispatchOutboxEvent", () => {
|
||||
expect(result.response.externalId).toContain("BX-001");
|
||||
});
|
||||
|
||||
it("maps ERP disabled as non-retryable dispatch error", async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
config: {
|
||||
erpSyncEnabled: false,
|
||||
erpSyncProvider: "mock",
|
||||
},
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: {
|
||||
reimbursementId: "rb-2",
|
||||
formNumber: "BX-002",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "erp_sync_disabled",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
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: "查看详情",
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
it("maps invalid ERP provider as non-retryable dispatch error", async () => {
|
||||
await expect(
|
||||
dispatchOutboxEvent({
|
||||
config: {
|
||||
erpSyncEnabled: true,
|
||||
erpSyncProvider: "invalid-provider",
|
||||
},
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: {
|
||||
reimbursementId: "rb-3",
|
||||
formNumber: "BX-003",
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "IntegrationDispatchError",
|
||||
code: "erp_sync_provider_invalid",
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles IM notification outbox", async () => {
|
||||
const result = await dispatchOutboxEvent({
|
||||
config: {},
|
||||
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("mock_sent");
|
||||
expect(result.response.channelType).toBe("wecom");
|
||||
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 () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户