R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关

这个提交包含在:
2026-04-05 11:21:39 +08:00
父节点 ff0a677bf3
当前提交 47e10e6acc
共修改 24 个文件,包含 1701 行新增和 197 行删除
+140
查看文件
@@ -0,0 +1,140 @@
const Fastify = require('fastify');
const meRoutes = require('../../../src/routes/me');
const { AppError } = require('../../../src/utils/AppError');
function createPrismaState(overrides = {}) {
const auditEntries = [];
const employeeRecord = {
id: 'employee-1',
employee_no: 'E001',
name: '测试员工',
phone: '13800000000',
email: '[email protected]',
department_id: 'department-1',
direct_manager_id: 'manager-1',
status: 'active',
notification_push_enabled: true,
roles: [{ role_code: 'employee' }],
wecom_identity_binding: null,
...overrides,
};
return {
prisma: {
employee: {
findUnique: vi.fn().mockResolvedValue(employeeRecord),
update: vi.fn().mockImplementation(async ({ data }) => {
employeeRecord.notification_push_enabled = data.notification_push_enabled;
return {
id: employeeRecord.id,
notification_push_enabled: employeeRecord.notification_push_enabled,
};
}),
},
auditLog: {
create: vi.fn().mockImplementation(async ({ data }) => {
auditEntries.push(data);
return { id: `audit-${auditEntries.length}` };
}),
},
},
employeeRecord,
auditEntries,
};
}
async function buildApp(prisma) {
const app = Fastify();
app.decorate('prisma', prisma);
app.decorate('authenticate', async (request) => {
if (!request.headers.authorization) {
throw new AppError({
code: 'AUTH_001',
message: '未登录',
statusCode: 401,
});
}
request.employee = {
id: 'employee-1',
permissionCodes: [],
};
});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: {
code: error.code,
message: error.message,
details: error.details ?? {},
},
});
}
return reply.status(500).send({
requestId: request.id,
error: {
code: 'SYS_001',
message: '服务器内部错误',
details: {},
},
});
});
await app.register(meRoutes, { prefix: '/api' });
return app;
}
describe('me routes', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('returns notificationPushEnabled in /me response', async () => {
const { prisma } = createPrismaState({ notification_push_enabled: false });
const app = await buildApp(prisma);
const response = await app.inject({
method: 'GET',
url: '/api/me',
headers: { authorization: 'Bearer test-token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual(
expect.objectContaining({
id: 'employee-1',
notificationPushEnabled: false,
}),
);
await app.close();
});
it('updates notification preferences for current employee', async () => {
const { prisma, employeeRecord, auditEntries } = createPrismaState();
const app = await buildApp(prisma);
const response = await app.inject({
method: 'PATCH',
url: '/api/me/notification-preferences',
headers: { authorization: 'Bearer test-token' },
payload: {
notificationPushEnabled: false,
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({ notificationPushEnabled: false });
expect(employeeRecord.notification_push_enabled).toBe(false);
expect(auditEntries[0]).toEqual(
expect.objectContaining({
action: 'EMPLOYEE_NOTIFICATION_PREFERENCE_UPDATED',
}),
);
await app.close();
});
});
+133 -1
查看文件
@@ -32,8 +32,45 @@ function buildChannelRecord(overrides = {}) {
};
}
function createPrismaState({ channels = [] } = {}) {
function buildNotificationRecord(overrides = {}) {
const now = new Date('2026-03-13T09:00:00.000Z');
return {
id: '22222222-2222-4222-8222-222222222222',
recipient_employee_id: 'employee-2',
type: 'workflow.task_assigned',
title: '【待办】采购申请待审批',
body: '请及时处理审批任务。',
target_type: 'workflow_task',
target_id: '33333333-3333-4333-8333-333333333333',
read_at: null,
created_at: now,
...overrides,
};
}
function buildDeliveryRecord(overrides = {}) {
const notification = overrides.notification ?? buildNotificationRecord();
const channel = overrides.channel ?? buildChannelRecord();
const now = new Date('2026-03-13T10:00:00.000Z');
return {
id: '44444444-4444-4444-8444-444444444444',
notification_id: notification.id,
channel_id: channel.id,
status: 'success',
attempt_no: 1,
result_code: 'mock_sent',
error_message: null,
response_body: { mode: 'mock' },
attempted_at: now,
notification,
channel,
...overrides,
};
}
function createPrismaState({ channels = [], deliveries = [] } = {}) {
const stateChannels = channels.map((channel) => ({ ...channel }));
const stateDeliveries = deliveries.map((delivery) => ({ ...delivery }));
const auditEntries = [];
const notificationChannel = {
@@ -117,6 +154,26 @@ function createPrismaState({ channels = [] } = {}) {
return {
prisma: {
notificationChannel,
notificationDelivery: {
findMany: vi.fn().mockImplementation(async ({ where, take }) => {
let items = [...stateDeliveries];
if (where?.notification_id) {
items = items.filter((item) => item.notification_id === where.notification_id);
}
if (where?.channel_id) {
items = items.filter((item) => item.channel_id === where.channel_id);
}
items.sort((left, right) => {
const timeDiff = right.attempted_at.getTime() - left.attempted_at.getTime();
if (timeDiff !== 0) {
return timeDiff;
}
return Number(right.attempt_no ?? 0) - Number(left.attempt_no ?? 0);
});
return items.slice(0, take ?? items.length);
}),
},
auditLog: {
create: vi.fn().mockImplementation(async ({ data }) => {
auditEntries.push(data);
@@ -443,4 +500,79 @@ describe('notifications channel routes', () => {
await app.close();
});
it('lists grouped delivery summaries with latest status and attempt count', async () => {
const notification = buildNotificationRecord({
id: '55555555-5555-4555-8555-555555555555',
title: '【待办】采购申请 CG-001 待审批',
});
const channel = buildChannelRecord();
const { prisma } = createPrismaState({
channels: [channel],
deliveries: [
buildDeliveryRecord({
id: 'delivery-2',
notification,
channel,
notification_id: notification.id,
channel_id: channel.id,
status: 'failed',
attempt_no: 2,
result_code: 'wecom_timeout',
error_message: 'timeout',
attempted_at: new Date('2026-03-13T10:02:00.000Z'),
}),
buildDeliveryRecord({
id: 'delivery-1',
notification,
channel,
notification_id: notification.id,
channel_id: channel.id,
status: 'failed',
attempt_no: 1,
result_code: 'wecom_http_429',
error_message: 'rate limit',
attempted_at: new Date('2026-03-13T10:01:00.000Z'),
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'GET',
url: '/api/notifications/deliveries?status=failed',
headers: { authorization: 'Bearer test-token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toEqual([
expect.objectContaining({
notificationId: notification.id,
channelId: channel.id,
channelName: channel.name,
notificationTitle: notification.title,
status: 'failed',
attemptCount: 2,
resultCode: 'wecom_timeout',
}),
]);
await app.close();
});
it('rejects delivery list without notification.channel.manage permission', async () => {
const { prisma } = createPrismaState();
const app = await buildApp({ prisma, permissionCodes: [] });
const response = await app.inject({
method: 'GET',
url: '/api/notifications/deliveries',
headers: { authorization: 'Bearer test-token' },
});
expect(response.statusCode).toBe(403);
expect(response.json().error.code).toBe('PERM_001');
await app.close();
});
});
@@ -31,11 +31,15 @@ describe('purchase submit side effects helper', () => {
},
}),
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
enqueueIm: vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
createAppNotifications: vi.fn().mockResolvedValue([
{
id: 'notification-1',
recipient_employee_id: 'manager-1',
},
]),
dispatchNotifications: vi.fn().mockResolvedValue({
deliveredChannels: [{ channelId: 'channel-1', status: 'success' }],
skippedReason: null,
}),
sendEmailSafe: vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
@@ -50,7 +54,7 @@ describe('purchase submit side effects helper', () => {
warn: vi.fn(),
};
const createAppNotifications = vi.fn();
const enqueueIm = vi.fn();
const dispatchNotifications = vi.fn();
const sendEmailSafe = vi.fn();
const result = await finalizePurchaseSubmitSideEffects(
@@ -58,20 +62,20 @@ describe('purchase submit side effects helper', () => {
logger,
resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')),
createAppNotifications,
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(createAppNotifications).not.toHaveBeenCalled();
expect(enqueueIm).not.toHaveBeenCalled();
expect(dispatchNotifications).not.toHaveBeenCalled();
expect(sendEmailSafe).not.toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: [],
notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }),
dispatch: expect.objectContaining({ skippedReason: 'recipient_resolution_failed' }),
email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
}),
);
@@ -82,7 +86,7 @@ describe('purchase submit side effects helper', () => {
error: vi.fn(),
warn: vi.fn(),
};
const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed'));
const dispatchNotifications = vi.fn().mockRejectedValue(new Error('dispatch failed'));
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
});
@@ -91,30 +95,34 @@ describe('purchase submit side effects helper', () => {
createParams({
logger,
createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')),
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(2);
expect(enqueueIm).toHaveBeenCalledTimes(1);
expect(dispatchNotifications).toHaveBeenCalledTimes(1);
expect(sendEmailSafe).toHaveBeenCalledTimes(1);
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: ['manager-1'],
notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }),
dispatch: expect.objectContaining({ skippedReason: 'dispatch_failed' }),
email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }),
}),
);
});
it('builds structured purchase copy for app notification, IM and email', async () => {
const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 });
const enqueueIm = vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
const createAppNotifications = vi.fn().mockResolvedValue([
{
id: 'notification-1',
recipient_employee_id: 'manager-1',
},
]);
const dispatchNotifications = vi.fn().mockResolvedValue({
deliveredChannels: [{ channelId: 'channel-1', status: 'success' }],
skippedReason: null,
});
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
@@ -123,7 +131,7 @@ describe('purchase submit side effects helper', () => {
await finalizePurchaseSubmitSideEffects(
createParams({
createAppNotifications,
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
@@ -135,11 +143,16 @@ describe('purchase submit side effects helper', () => {
body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(enqueueIm).toHaveBeenCalledWith(
{},
expect(dispatchNotifications).toHaveBeenCalledWith(
expect.objectContaining({
prisma: {},
title: '【待办】采购申请 CG-20260324-001 待审批',
body: '申请人:李经理\n金额:5600.00\n单号:CG-20260324-001\n动作:请及时处理审批任务。',
notifications: [
expect.objectContaining({
id: 'notification-1',
}),
],
}),
);
expect(sendEmailSafe).toHaveBeenCalledWith(
@@ -31,11 +31,15 @@ describe('reimbursement submit side effects helper', () => {
},
}),
resolveRecipients: vi.fn().mockResolvedValue(['manager-1']),
createAppNotifications: vi.fn().mockResolvedValue({ count: 1 }),
enqueueIm: vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
createAppNotifications: vi.fn().mockResolvedValue([
{
id: 'notification-1',
recipient_employee_id: 'manager-1',
},
]),
dispatchNotifications: vi.fn().mockResolvedValue({
deliveredChannels: [{ channelId: 'channel-1', status: 'success' }],
skippedReason: null,
}),
sendEmailSafe: vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
@@ -50,7 +54,7 @@ describe('reimbursement submit side effects helper', () => {
warn: vi.fn(),
};
const createAppNotifications = vi.fn();
const enqueueIm = vi.fn();
const dispatchNotifications = vi.fn();
const sendEmailSafe = vi.fn();
const result = await finalizeReimbursementSubmitSideEffects(
@@ -58,20 +62,20 @@ describe('reimbursement submit side effects helper', () => {
logger,
resolveRecipients: vi.fn().mockRejectedValue(new Error('resolve failed')),
createAppNotifications,
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(createAppNotifications).not.toHaveBeenCalled();
expect(enqueueIm).not.toHaveBeenCalled();
expect(dispatchNotifications).not.toHaveBeenCalled();
expect(sendEmailSafe).not.toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: [],
notification: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'recipient_resolution_failed' }),
dispatch: expect.objectContaining({ skippedReason: 'recipient_resolution_failed' }),
email: expect.objectContaining({ skipped: true, reason: 'recipient_resolution_failed' }),
}),
);
@@ -82,7 +86,7 @@ describe('reimbursement submit side effects helper', () => {
error: vi.fn(),
warn: vi.fn(),
};
const enqueueIm = vi.fn().mockRejectedValue(new Error('im failed'));
const dispatchNotifications = vi.fn().mockRejectedValue(new Error('dispatch failed'));
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
});
@@ -91,30 +95,34 @@ describe('reimbursement submit side effects helper', () => {
createParams({
logger,
createAppNotifications: vi.fn().mockRejectedValue(new Error('notifications failed')),
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
expect(logger.error).toHaveBeenCalledTimes(2);
expect(enqueueIm).toHaveBeenCalledTimes(1);
expect(dispatchNotifications).toHaveBeenCalledTimes(1);
expect(sendEmailSafe).toHaveBeenCalledTimes(1);
expect(result).toEqual(
expect.objectContaining({
recipientEmployeeIds: ['manager-1'],
notification: expect.objectContaining({ skipped: true, reason: 'notification_failed' }),
im: expect.objectContaining({ queued: 0, skippedReason: 'enqueue_im_failed' }),
dispatch: expect.objectContaining({ skippedReason: 'dispatch_failed' }),
email: expect.objectContaining({ skipped: true, sent: 0, failed: 0 }),
}),
);
});
it('builds structured reimbursement copy for app notification, IM and email', async () => {
const createAppNotifications = vi.fn().mockResolvedValue({ count: 1 });
const enqueueIm = vi.fn().mockResolvedValue({
queued: 1,
duplicated: 0,
matchedChannels: [{ id: 'channel-1' }],
const createAppNotifications = vi.fn().mockResolvedValue([
{
id: 'notification-1',
recipient_employee_id: 'manager-1',
},
]);
const dispatchNotifications = vi.fn().mockResolvedValue({
deliveredChannels: [{ channelId: 'channel-1', status: 'success' }],
skippedReason: null,
});
const sendEmailSafe = vi.fn().mockResolvedValue({
email: { skipped: true, sent: 0, failed: 0 },
@@ -123,7 +131,7 @@ describe('reimbursement submit side effects helper', () => {
await finalizeReimbursementSubmitSideEffects(
createParams({
createAppNotifications,
enqueueIm,
dispatchNotifications,
sendEmailSafe,
}),
);
@@ -135,11 +143,16 @@ describe('reimbursement submit side effects helper', () => {
body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。',
}),
);
expect(enqueueIm).toHaveBeenCalledWith(
{},
expect(dispatchNotifications).toHaveBeenCalledWith(
expect.objectContaining({
prisma: {},
title: '【待办】报销单 BX-20260324-001 待审批',
body: '申请人:王小明\n金额:1288.00\n单号:BX-20260324-001\n动作:请及时处理审批任务。',
notifications: [
expect.objectContaining({
id: 'notification-1',
}),
],
}),
);
expect(sendEmailSafe).toHaveBeenCalledWith(
@@ -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,
}),
);
});
});