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 () => {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
const {
|
||||
buildDetailUrl,
|
||||
buildImDedupeKey,
|
||||
enqueueImNotifications,
|
||||
resolveRecipientRoleCodes,
|
||||
} = require("../../../src/services/notifications/imEnqueue");
|
||||
|
||||
describe("IM enqueue", () => {
|
||||
it("builds detail url from APP_ORIGIN", () => {
|
||||
expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe(
|
||||
"https://app.example.com/workflow/tasks/1",
|
||||
);
|
||||
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds dedupe key with target id first", () => {
|
||||
expect(
|
||||
buildImDedupeKey({
|
||||
notificationType: "workflow.task_assigned",
|
||||
targetType: "workflow_task",
|
||||
targetId: "task-1",
|
||||
targetPath: "/workflow/tasks/task-1",
|
||||
channelId: "channel-1",
|
||||
}),
|
||||
).toBe("workflow.task_assigned:workflow_task:task-1:channel-1");
|
||||
});
|
||||
|
||||
it("resolves unique recipient role codes", async () => {
|
||||
const roles = await resolveRecipientRoleCodes(
|
||||
{
|
||||
employeeRole: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ role_code: "manager" },
|
||||
{ role_code: "manager" },
|
||||
{ role_code: "finance_mgr" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
["employee-1", "employee-2"],
|
||||
);
|
||||
|
||||
expect(roles).toEqual(["manager", "finance_mgr"]);
|
||||
});
|
||||
|
||||
it("enqueues one outbox event per matched channel", async () => {
|
||||
const tx = {
|
||||
employeeRole: {
|
||||
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
|
||||
},
|
||||
notificationChannel: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "channel-1",
|
||||
channel_type: "wecom",
|
||||
name: "manager-workflow",
|
||||
enabled: true,
|
||||
config_json: {
|
||||
routing: {
|
||||
roleCodes: ["manager"],
|
||||
domainScopes: ["workflow"],
|
||||
allowedTypes: ["workflow.task_assigned"],
|
||||
},
|
||||
},
|
||||
template_json: null,
|
||||
},
|
||||
{
|
||||
id: "channel-2",
|
||||
channel_type: "wechat_work",
|
||||
name: "all-workflow",
|
||||
enabled: true,
|
||||
config_json: {
|
||||
routing: {
|
||||
roleCodes: [],
|
||||
domainScopes: ["workflow"],
|
||||
allowedTypes: [],
|
||||
},
|
||||
},
|
||||
template_json: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "outbox-2", status: "pending" }),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => ({ id: `${data.dedupe_key}-id`, status: "pending" })),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await enqueueImNotifications(tx, {
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
integrationOutboxMaxAttempts: 5,
|
||||
appOrigin: "https://app.example.com",
|
||||
},
|
||||
logger: { warn: vi.fn() },
|
||||
recipientEmployeeIds: ["employee-1"],
|
||||
type: "workflow.task_assigned",
|
||||
title: "【待办】采购申请待审批",
|
||||
body: "采购申请已流转到你,请及时处理。",
|
||||
targetType: "workflow_task",
|
||||
targetId: "task-1",
|
||||
targetPath: "/workflow/tasks/task-1",
|
||||
entityType: "purchase_request",
|
||||
entityId: "pr-1",
|
||||
actorId: "actor-1",
|
||||
});
|
||||
|
||||
expect(result.queued).toBe(1);
|
||||
expect(result.duplicated).toBe(1);
|
||||
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
|
||||
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
|
||||
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe(
|
||||
"https://app.example.com/workflow/tasks/task-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("safely skips when no channel matches", async () => {
|
||||
const tx = {
|
||||
employeeRole: {
|
||||
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
|
||||
},
|
||||
notificationChannel: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "channel-3",
|
||||
channel_type: "wecom",
|
||||
name: "finance-only",
|
||||
enabled: true,
|
||||
config_json: {
|
||||
routing: {
|
||||
roleCodes: ["finance_mgr"],
|
||||
domainScopes: ["procurement"],
|
||||
allowedTypes: ["purchase_request.approved"],
|
||||
},
|
||||
},
|
||||
template_json: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await enqueueImNotifications(tx, {
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
integrationOutboxMaxAttempts: 5,
|
||||
appOrigin: "https://app.example.com",
|
||||
},
|
||||
logger: { warn: vi.fn() },
|
||||
recipientEmployeeIds: ["employee-1"],
|
||||
type: "workflow.task_assigned",
|
||||
title: "title",
|
||||
body: "body",
|
||||
targetType: "workflow_task",
|
||||
targetId: "task-1",
|
||||
targetPath: "/workflow/tasks/task-1",
|
||||
entityType: "purchase_request",
|
||||
entityId: "pr-1",
|
||||
actorId: "actor-1",
|
||||
});
|
||||
|
||||
expect(result.skippedReason).toBe("no_matching_channel");
|
||||
expect(tx.integrationOutbox.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const {
|
||||
isImChannelTypeSupported,
|
||||
matchesNotificationChannel,
|
||||
normalizeChannelType,
|
||||
normalizeRouting,
|
||||
} = require("../../../src/services/notifications/imRouting");
|
||||
|
||||
describe("IM routing", () => {
|
||||
it("normalizes wechat_work alias to wecom", () => {
|
||||
expect(normalizeChannelType("wechat_work")).toBe("wecom");
|
||||
expect(isImChannelTypeSupported("wecom")).toBe(true);
|
||||
expect(isImChannelTypeSupported("dingtalk")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes routing arrays and drops empty values", () => {
|
||||
expect(
|
||||
normalizeRouting({
|
||||
routing: {
|
||||
roleCodes: ["manager", "manager", ""],
|
||||
domainScopes: ["workflow", "workflow"],
|
||||
allowedTypes: ["workflow.task_assigned", ""],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
roleCodes: ["manager"],
|
||||
domainScopes: ["workflow"],
|
||||
allowedTypes: ["workflow.task_assigned"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches channel when role scope type all hit", () => {
|
||||
const matched = matchesNotificationChannel(
|
||||
{
|
||||
enabled: true,
|
||||
channel_type: "wecom",
|
||||
config_json: {
|
||||
routing: {
|
||||
roleCodes: ["manager"],
|
||||
domainScopes: ["workflow"],
|
||||
allowedTypes: ["workflow.task_assigned"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
notificationType: "workflow.task_assigned",
|
||||
domainScope: "workflow",
|
||||
recipientRoleCodes: ["manager", "finance_mgr"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(matched).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects channel when any routing dimension does not match", () => {
|
||||
const baseChannel = {
|
||||
enabled: true,
|
||||
channel_type: "wecom",
|
||||
config_json: {
|
||||
routing: {
|
||||
roleCodes: ["manager"],
|
||||
domainScopes: ["workflow"],
|
||||
allowedTypes: ["workflow.task_assigned"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchesNotificationChannel(baseChannel, {
|
||||
notificationType: "workflow.task_assigned",
|
||||
domainScope: "procurement",
|
||||
recipientRoleCodes: ["manager"],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesNotificationChannel(baseChannel, {
|
||||
notificationType: "workflow.task_assigned",
|
||||
domainScope: "workflow",
|
||||
recipientRoleCodes: ["finance_mgr"],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesNotificationChannel(baseChannel, {
|
||||
notificationType: "leave.approved",
|
||||
domainScope: "workflow",
|
||||
recipientRoleCodes: ["manager"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,46 @@
|
||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||
|
||||
describe("Integration Dispatcher", () => {
|
||||
it("dispatches erp.reimbursement.paid", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
});
|
||||
expect(result.channel).toBe("erp");
|
||||
expect(result.code).toBe("mock_synced");
|
||||
it("dispatches erp.reimbursement.paid", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: "rb-1", formNumber: "BX-001" },
|
||||
});
|
||||
|
||||
it("throws IntegrationDispatchError if erp fails", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: false },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
})
|
||||
).rejects.toThrow(dispatcher.IntegrationDispatchError);
|
||||
expect(result.channel).toBe("erp");
|
||||
expect(result.code).toBe("mock_synced");
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
it("dispatches notification.im", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "notification.im",
|
||||
payload: { channelType: "slack" },
|
||||
});
|
||||
expect(result.channel).toBe("im");
|
||||
expect(result.response.channelType).toBe("slack");
|
||||
});
|
||||
expect(result.channel).toBe("im");
|
||||
expect(result.code).toBe("mock_sent");
|
||||
});
|
||||
|
||||
it("throws error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "unknown.event",
|
||||
})
|
||||
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
||||
});
|
||||
it("throws error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "unknown.event",
|
||||
}),
|
||||
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户