R50 通知推送:dispatcher 投递服务、业务事件接入与员工推送开关
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户