W1-W3 实现企业微信个人应用消息通知
这个提交包含在:
@@ -1,6 +1,12 @@
|
||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||
const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken");
|
||||
|
||||
describe("Integration Dispatcher", () => {
|
||||
afterEach(() => {
|
||||
clearWecomAppAccessTokenCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("dispatches erp.reimbursement.paid", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
@@ -72,6 +78,112 @@ describe("Integration Dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -41,6 +41,9 @@ function createPrismaState({ employees = [], channels = [] } = {}) {
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })),
|
||||
wecom_identity_binding: item.externalUserId
|
||||
? { external_user_id: item.externalUserId }
|
||||
: null,
|
||||
}));
|
||||
}),
|
||||
},
|
||||
@@ -186,6 +189,65 @@ describe('notificationDispatcher', () => {
|
||||
expect(deliveryState[0]).toEqual(expect.objectContaining({ status: 'success' }));
|
||||
});
|
||||
|
||||
it('passes bound wecom user ids to channel sender', async () => {
|
||||
const { prisma } = createPrismaState({
|
||||
employees: [
|
||||
{
|
||||
id: 'manager-1',
|
||||
status: 'active',
|
||||
notification_push_enabled: true,
|
||||
roles: ['manager'],
|
||||
externalUserId: 'oa_smoke_02',
|
||||
},
|
||||
],
|
||||
channels: [buildChannel({ config_json: { mode: 'wecom_app_message', routing: {} } })],
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
retryable: false,
|
||||
code: 'wecom_app_sent',
|
||||
message: null,
|
||||
response: { errcode: 0 },
|
||||
});
|
||||
|
||||
await dispatchNotificationBatch({
|
||||
prisma,
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
appOrigin: 'https://oa.example.com',
|
||||
jwtSecret: 'test-secret',
|
||||
},
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
requestId: 'req-wecom-bound',
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-1',
|
||||
recipient_employee_id: 'manager-1',
|
||||
target_type: 'workflow_task',
|
||||
},
|
||||
],
|
||||
type: 'workflow.task_assigned',
|
||||
title: '测试待办',
|
||||
body: '请处理审批',
|
||||
targetType: 'workflow_task',
|
||||
targetId: 'task-1',
|
||||
targetPath: '/workflow/tasks/task-1',
|
||||
sendMessage,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessage.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
prisma,
|
||||
payload: expect.objectContaining({
|
||||
recipientEmployeeIds: ['manager-1'],
|
||||
recipientExternalUserIds: ['oa_smoke_02'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries retryable failures until a later success', async () => {
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
|
||||
在新工单中引用
屏蔽一个用户