汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
193 行
6.3 KiB
JavaScript
193 行
6.3 KiB
JavaScript
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");
|
|
});
|
|
});
|