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(
|
||||
|
||||
在新工单中引用
屏蔽一个用户