R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关
这个提交包含在:
@@ -0,0 +1,363 @@
|
||||
const { dispatchNotificationBatch } = require('../../../src/services/notificationDispatcher');
|
||||
|
||||
function buildChannel(overrides = {}) {
|
||||
return {
|
||||
id: 'channel-1',
|
||||
channel_type: 'wecom',
|
||||
name: '审批通知群',
|
||||
enabled: true,
|
||||
config_json: {
|
||||
mode: 'mock',
|
||||
webhookUrl: null,
|
||||
timeoutMs: 5000,
|
||||
routing: {
|
||||
roleCodes: [],
|
||||
domainScopes: [],
|
||||
allowedTypes: [],
|
||||
},
|
||||
},
|
||||
template_json: {
|
||||
title: '{{title}}',
|
||||
body: '{{body}}',
|
||||
footer: '来自海音达工作台',
|
||||
deepLinkLabel: '查看详情',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPrismaState({ employees = [], channels = [] } = {}) {
|
||||
const deliveryState = [];
|
||||
|
||||
return {
|
||||
prisma: {
|
||||
employee: {
|
||||
findMany: vi.fn().mockImplementation(async ({ where }) => {
|
||||
const ids = new Set(where?.id?.in ?? []);
|
||||
return employees
|
||||
.filter((item) => ids.has(item.id))
|
||||
.filter((item) => item.status === 'active')
|
||||
.filter((item) => item.notification_push_enabled !== false)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })),
|
||||
}));
|
||||
}),
|
||||
},
|
||||
notificationChannel: {
|
||||
findMany: vi.fn().mockResolvedValue(channels),
|
||||
},
|
||||
notificationDelivery: {
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
const row = {
|
||||
id: `delivery-${deliveryState.length + 1}`,
|
||||
...data,
|
||||
};
|
||||
deliveryState.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn().mockImplementation(async ({ where, data }) => {
|
||||
const current = deliveryState.find((item) => item.id === where.id);
|
||||
Object.assign(current, data);
|
||||
return current;
|
||||
}),
|
||||
},
|
||||
},
|
||||
deliveryState,
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationDispatcher', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('dispatches a mock channel and persists a success attempt', async () => {
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
{ id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] },
|
||||
],
|
||||
channels: [buildChannel()],
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
retryable: false,
|
||||
code: 'mock_sent',
|
||||
message: null,
|
||||
response: { mode: 'mock' },
|
||||
});
|
||||
|
||||
const result = 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-1',
|
||||
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(deliveryState).toHaveLength(1);
|
||||
expect(deliveryState[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
notification_id: 'notification-1',
|
||||
channel_id: 'channel-1',
|
||||
status: 'success',
|
||||
result_code: 'mock_sent',
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
matchedChannelCount: 1,
|
||||
eligibleRecipientCount: 1,
|
||||
deliveredChannels: [
|
||||
expect.objectContaining({
|
||||
channelId: 'channel-1',
|
||||
status: 'success',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries retryable failures until a later success', async () => {
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
{ id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] },
|
||||
],
|
||||
channels: [buildChannel()],
|
||||
});
|
||||
const sendMessage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
retryable: true,
|
||||
code: 'wecom_timeout',
|
||||
message: 'timeout',
|
||||
response: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
retryable: true,
|
||||
code: 'wecom_http_429',
|
||||
message: 'rate limited',
|
||||
response: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
retryable: false,
|
||||
code: 'wecom_sent',
|
||||
message: null,
|
||||
response: { errcode: 0 },
|
||||
});
|
||||
|
||||
const result = 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-2',
|
||||
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',
|
||||
sendMessage,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(3);
|
||||
expect(deliveryState).toHaveLength(3);
|
||||
expect(deliveryState.map((item) => item.status)).toEqual(['failed', 'failed', 'success']);
|
||||
expect(result.deliveredChannels[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'success',
|
||||
attemptNo: 3,
|
||||
resultCode: 'wecom_sent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry when provider marks the failure as non-retryable', async () => {
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
{ id: 'manager-1', status: 'active', notification_push_enabled: true, roles: ['manager'] },
|
||||
],
|
||||
channels: [buildChannel()],
|
||||
});
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
retryable: false,
|
||||
code: 'wecom_api_error',
|
||||
message: 'invalid webhook',
|
||||
response: { errcode: 93000 },
|
||||
});
|
||||
|
||||
const result = 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-3',
|
||||
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',
|
||||
sendMessage,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(deliveryState).toHaveLength(1);
|
||||
expect(result.deliveredChannels[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
attemptNo: 1,
|
||||
resultCode: 'wecom_api_error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips dispatch when all recipients opted out of external push', async () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
{ id: 'manager-1', status: 'active', notification_push_enabled: false, roles: ['manager'] },
|
||||
],
|
||||
channels: [buildChannel()],
|
||||
});
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const result = await dispatchNotificationBatch({
|
||||
prisma,
|
||||
config: {
|
||||
imNotifierEnabled: true,
|
||||
appOrigin: 'https://oa.example.com',
|
||||
jwtSecret: 'test-secret',
|
||||
},
|
||||
logger,
|
||||
requestId: 'req-4',
|
||||
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',
|
||||
sendMessage,
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(deliveryState).toHaveLength(0);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
skippedReason: 'no_eligible_recipients',
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notificationType: 'workflow.task_assigned',
|
||||
skippedReason: 'no_eligible_recipients',
|
||||
}),
|
||||
'Notification dispatch skipped',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips when no channel matches recipient roles and notification scope', async () => {
|
||||
const { prisma, deliveryState } = createPrismaState({
|
||||
employees: [
|
||||
{ id: 'employee-1', status: 'active', notification_push_enabled: true, roles: ['employee'] },
|
||||
],
|
||||
channels: [
|
||||
buildChannel({
|
||||
config_json: {
|
||||
mode: 'mock',
|
||||
webhookUrl: null,
|
||||
timeoutMs: 5000,
|
||||
routing: {
|
||||
roleCodes: ['manager'],
|
||||
domainScopes: ['workflow'],
|
||||
allowedTypes: ['workflow.task_assigned'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = 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-5',
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-1',
|
||||
recipient_employee_id: 'employee-1',
|
||||
target_type: 'workflow_task',
|
||||
},
|
||||
],
|
||||
type: 'workflow.task_assigned',
|
||||
title: '测试待办',
|
||||
body: '请处理审批',
|
||||
targetType: 'workflow_task',
|
||||
targetId: 'task-1',
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
|
||||
expect(deliveryState).toHaveLength(0);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
skippedReason: 'no_matching_channel',
|
||||
eligibleRecipientCount: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户