文件
hyb-oa/server/tests/unit/services/notificationDispatcher.test.js

475 行
13 KiB
JavaScript

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 })),
wecom_identity_binding: item.externalUserId
? { external_user_id: item.externalUserId }
: null,
}));
}),
},
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(sendMessage.mock.calls[0][0].payload.detailUrl).toMatch(
/^https:\/\/oa\.example\.com\/api\/auth\/deeplink\?token=/,
);
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('does not pass external target URLs to WeCom messages', 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' },
});
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-external-target',
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: 'https://evil.example.com/workflow/tasks/task-1',
sendMessage,
retryDelayMs: 0,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0][0].payload.detailUrl).toBeNull();
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: [
{ 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,
}),
);
});
});