文件
hyb-oa/server/tests/unit/routes/notifications.test.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

947 行
31 KiB
JavaScript

const Fastify = require('fastify');
const { AppError } = require('../../../src/utils/AppError');
const { NOTIFICATION_TYPES } = require('../../../src/constants/notificationTypes');
const { encryptStringOrThrow } = require('../../../src/services/crypto');
const notificationsRoutes = require('../../../src/routes/notifications');
function buildChannelRecord(overrides = {}) {
const now = new Date('2026-03-13T00:00:00.000Z');
return {
id: '11111111-1111-4111-8111-111111111111',
channel_type: 'wecom',
name: '采购经理群',
enabled: true,
config_json: {
mode: 'mock',
webhookUrl: null,
timeoutMs: 5000,
routing: {},
},
template_json: {
title: '{{title}}',
body: '{{body}}',
footer: '来自海音达工作台',
deepLinkLabel: '查看详情',
},
created_at: now,
created_by: 'employee-1',
updated_at: now,
updated_by: 'employee-1',
...overrides,
};
}
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 applySelect(record, select) {
if (!select) {
return { ...record };
}
return Object.fromEntries(
Object.entries(select)
.filter(([, enabled]) => Boolean(enabled))
.map(([key]) => [key, record[key]]),
);
}
function matchNotificationWhere(item, where = {}) {
if (where.recipient_employee_id && item.recipient_employee_id !== where.recipient_employee_id) {
return false;
}
if (Object.prototype.hasOwnProperty.call(where, 'read_at') && where.read_at === null && item.read_at !== null) {
return false;
}
const typeFilter = where.type?.in ?? null;
if (Array.isArray(typeFilter) && typeFilter.length > 0 && !typeFilter.includes(item.type)) {
return false;
}
return true;
}
function createPrismaState({ channels = [], deliveries = [], notifications = [] } = {}) {
const stateChannels = channels.map((channel) => ({ ...channel }));
const stateDeliveries = deliveries.map((delivery) => ({ ...delivery }));
const stateNotifications = notifications.map((notification) => ({ ...notification }));
const auditEntries = [];
const notificationChannel = {
findMany: vi.fn().mockImplementation(async () =>
[...stateChannels].sort((left, right) => {
const typeOrder = String(left.channel_type).localeCompare(String(right.channel_type));
if (typeOrder !== 0) {
return typeOrder;
}
return String(left.name).localeCompare(String(right.name));
}),
),
findUnique: vi.fn().mockImplementation(async ({ where }) => {
if (where?.id) {
return stateChannels.find((item) => item.id === where.id) ?? null;
}
if (where?.channel_type_name) {
return (
stateChannels.find(
(item) =>
item.channel_type === where.channel_type_name.channel_type && item.name === where.channel_type_name.name,
) ?? null
);
}
return null;
}),
create: vi.fn().mockImplementation(async ({ data }) => {
const duplicated = stateChannels.find(
(item) => item.channel_type === data.channel_type && item.name === data.name,
);
if (duplicated) {
const error = new Error('duplicate_channel_name');
error.code = 'P2002';
throw error;
}
const now = new Date('2026-03-13T10:00:00.000Z');
const channel = buildChannelRecord({
id: `00000000-0000-4000-8000-${String(stateChannels.length + 1).padStart(12, '0')}`,
channel_type: data.channel_type,
name: data.name,
enabled: data.enabled,
config_json: data.config_json,
template_json: data.template_json,
created_at: now,
updated_at: now,
created_by: data.created_by,
updated_by: data.updated_by,
});
stateChannels.push(channel);
return channel;
}),
update: vi.fn().mockImplementation(async ({ where, data }) => {
const index = stateChannels.findIndex((item) => item.id === where.id);
if (index < 0) {
return null;
}
const current = stateChannels[index];
const next = {
...current,
...data,
config_json: data.config_json ?? current.config_json,
template_json: data.template_json ?? current.template_json,
updated_at: new Date('2026-03-13T11:00:00.000Z'),
};
stateChannels[index] = next;
return next;
}),
upsert: vi.fn().mockImplementation(async ({ where, create, update }) => {
const current = await notificationChannel.findUnique({ where });
if (current) {
return notificationChannel.update({ where: { id: current.id }, data: update });
}
return notificationChannel.create({ data: create });
}),
};
const notification = {
findMany: vi.fn().mockImplementation(async ({ where, skip, take, select }) => {
const items = stateNotifications
.filter((item) => matchNotificationWhere(item, where))
.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())
.slice(skip ?? 0, (skip ?? 0) + (take ?? stateNotifications.length))
.map((item) => applySelect(item, select));
return items;
}),
count: vi.fn().mockImplementation(async ({ where }) => stateNotifications.filter((item) => matchNotificationWhere(item, where)).length),
updateMany: vi.fn().mockImplementation(async ({ where, data }) => {
let count = 0;
stateNotifications.forEach((item, index) => {
if (!matchNotificationWhere(item, where)) {
return;
}
stateNotifications[index] = {
...item,
...data,
};
count += 1;
});
return { count };
}),
findUnique: vi.fn().mockImplementation(async ({ where, select }) => {
const item = stateNotifications.find((notificationItem) => notificationItem.id === where.id);
return item ? applySelect(item, select) : null;
}),
update: vi.fn().mockImplementation(async ({ where, data, select }) => {
const index = stateNotifications.findIndex((item) => item.id === where.id);
if (index < 0) {
return null;
}
stateNotifications[index] = {
...stateNotifications[index],
...data,
};
return applySelect(stateNotifications[index], select);
}),
};
const transactionClient = {
notification,
$queryRaw: vi.fn().mockImplementation(async (_strings, notificationId) => {
return stateNotifications.some((item) => item.id === notificationId) ? [{ id: notificationId }] : [];
}),
};
return {
prisma: {
notification,
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);
return { id: `audit-${auditEntries.length}` };
}),
},
wecomIdentityBinding: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)),
},
stateChannels,
stateNotifications,
auditEntries,
};
}
async function buildApp({ prisma, config = {}, permissionCodes = ['notification.channel.manage'] }) {
const app = Fastify();
app.decorate('prisma', prisma);
app.decorate('config', {
fieldEncryptionKey: '',
imNotifierEnabled: true,
...config,
});
app.decorate('authenticate', async (request) => {
if (!request.headers.authorization) {
throw new AppError({
code: 'AUTH_001',
message: '未登录',
statusCode: 401,
});
}
request.employee = {
id: 'employee-1',
name: '测试人',
permissionCodes,
};
});
app.decorate('hasPermission', (employee, permissionCode) =>
(employee?.permissionCodes ?? []).includes('*') || (employee?.permissionCodes ?? []).includes(permissionCode),
);
app.decorate('requirePermission', (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({
code: 'PERM_001',
message: '无权限',
statusCode: 403,
});
}
});
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(notificationsRoutes, { prefix: '/api' });
return app;
}
describe('notifications channel routes', () => {
const validFieldEncryptionKey = Buffer.alloc(32, 7).toString('base64');
afterEach(() => {
vi.clearAllMocks();
});
it('lists channels with masked legacy secret only', async () => {
const { prisma } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secret: 'legacy-secret',
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'GET',
url: '/api/notifications/channels',
headers: { authorization: 'Bearer test-token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items[0].configJson).toEqual(
expect.objectContaining({
secretConfigured: true,
secretMasked: expect.any(String),
}),
);
expect(response.json().data.items[0].configJson.secret).toBeUndefined();
await app.close();
});
it('creates wecom webhook channel with encrypted secret and masked read model', async () => {
const { prisma, stateChannels } = createPrismaState();
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels',
headers: { authorization: 'Bearer test-token' },
payload: {
channelType: 'wecom',
name: '企微经理群',
enabled: true,
configJson: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secret: 'super-secret',
timeoutMs: 800,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow'],
allowedTypes: ['workflow.task_assigned'],
},
},
templateJson: {
title: '{{title}}',
body: '{{body}}',
footer: '来自海音达工作台',
deepLinkLabel: '查看详情',
},
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.channel.configJson).toEqual(
expect.objectContaining({
secretConfigured: true,
secretMasked: expect.any(String),
}),
);
expect(response.json().data.channel.configJson.secret).toBeUndefined();
expect(stateChannels[0].config_json.secretEncrypted).toMatch(/^v1:/);
expect(stateChannels[0].config_json.secret).toBeUndefined();
await app.close();
});
it('creates wecom app message channel without webhook or secret', async () => {
const { prisma, stateChannels } = createPrismaState();
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels',
headers: { authorization: 'Bearer test-token' },
payload: {
channelType: 'wecom',
name: '企微个人应用消息',
enabled: true,
configJson: {
mode: 'wecom_app_message',
timeoutMs: 800,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
},
},
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.channel.configJson).toEqual(
expect.objectContaining({
mode: 'wecom_app_message',
webhookUrl: null,
secretConfigured: false,
secretMasked: null,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow', 'leave', 'procurement', 'travel'],
allowedTypes: [
NOTIFICATION_TYPES.workflowTaskAssigned,
NOTIFICATION_TYPES.leaveReturned,
NOTIFICATION_TYPES.purchaseRequestReturned,
NOTIFICATION_TYPES.travelRequestReturned,
],
},
}),
);
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
expect(stateChannels[0].config_json.webhookUrl).toBeNull();
await app.close();
});
it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => {
const { prisma } = createPrismaState();
const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } });
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels',
headers: { authorization: 'Bearer test-token' },
payload: {
channelType: 'wecom',
name: '企微经理群',
enabled: true,
configJson: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secret: 'super-secret',
timeoutMs: 800,
routing: {},
},
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe('VALID_001');
expect(response.json().error.details).toEqual(expect.objectContaining({ secret: 'field_encryption_key_missing' }));
await app.close();
});
it('patches channel without replacing existing encrypted secret when secret omitted', async () => {
const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret');
const { prisma, stateChannels } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secretEncrypted: encryptedSecret,
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'PATCH',
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111',
headers: { authorization: 'Bearer test-token' },
payload: {
name: '企微经理群-更新',
configJson: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom-next',
timeoutMs: 900,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow'],
allowedTypes: [],
},
},
},
});
expect(response.statusCode).toBe(200);
expect(stateChannels[0].config_json.secretEncrypted).toBe(encryptedSecret);
expect(response.json().data.channel.configJson.secretConfigured).toBe(true);
expect(response.json().data.channel.configJson.secret).toBeUndefined();
await app.close();
});
it('clears secret on patch when clearSecret is true', async () => {
const encryptedSecret = encryptStringOrThrow({ fieldEncryptionKey: validFieldEncryptionKey }, 'super-secret');
const { prisma, stateChannels } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secretEncrypted: encryptedSecret,
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'PATCH',
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111',
headers: { authorization: 'Bearer test-token' },
payload: {
configJson: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
clearSecret: true,
timeoutMs: 800,
routing: {},
},
},
});
expect(response.statusCode).toBe(200);
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
expect(stateChannels[0].config_json.secret).toBeUndefined();
expect(response.json().data.channel.configJson.secretConfigured).toBe(false);
expect(response.json().data.channel.configJson.secretMasked).toBeNull();
await app.close();
});
it('returns message and retryable in test result without exposing plain secret', async () => {
const encryptedSecret = 'v1:bad:payload:value';
const { prisma } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_webhook',
webhookUrl: 'https://example.com/wecom',
secretEncrypted: encryptedSecret,
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test',
headers: { authorization: 'Bearer test-token' },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.channel.configJson.secret).toBeUndefined();
expect(response.json().data.testResult).toEqual(
expect.objectContaining({
ok: false,
code: 'wecom_secret_decrypt_failed',
message: 'Failed to decrypt notification channel secret',
retryable: false,
}),
);
await app.close();
});
it('tests wecom app message channel against current bound admin when available', async () => {
const { prisma } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_app_message',
webhookUrl: null,
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test',
headers: { authorization: 'Bearer test-token' },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.testResult).toEqual(
expect.objectContaining({
ok: true,
code: 'wecom_app_no_bound_recipients',
}),
);
expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { employee_id: { in: ['employee-1'] } },
}),
);
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();
});
});
describe('notification inbox routes', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('filters notifications by type while keeping unreadCount global', async () => {
const { prisma } = createPrismaState({
notifications: [
buildNotificationRecord({
id: '10000000-0000-4000-8000-000000000001',
recipient_employee_id: 'employee-1',
type: 'leave.approved',
created_at: new Date('2026-03-13T11:00:00.000Z'),
}),
buildNotificationRecord({
id: '10000000-0000-4000-8000-000000000002',
recipient_employee_id: 'employee-1',
type: 'reimbursement.approved',
created_at: new Date('2026-03-13T10:00:00.000Z'),
}),
buildNotificationRecord({
id: '10000000-0000-4000-8000-000000000003',
recipient_employee_id: 'employee-1',
type: 'leave.approved',
read_at: new Date('2026-03-13T09:30:00.000Z'),
created_at: new Date('2026-03-13T09:00:00.000Z'),
}),
buildNotificationRecord({
id: '10000000-0000-4000-8000-000000000004',
recipient_employee_id: 'employee-9',
type: 'leave.approved',
created_at: new Date('2026-03-13T08:00:00.000Z'),
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'GET',
url: '/api/notifications?type=leave.approved',
headers: { authorization: 'Bearer test-token' },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.total).toBe(2);
expect(response.json().data.unreadCount).toBe(2);
expect(response.json().data.items).toEqual([
expect.objectContaining({
id: '10000000-0000-4000-8000-000000000001',
type: 'leave.approved',
}),
expect.objectContaining({
id: '10000000-0000-4000-8000-000000000003',
type: 'leave.approved',
}),
]);
await app.close();
});
it('marks all unread notifications as read for current employee', async () => {
const { prisma, stateNotifications } = createPrismaState({
notifications: [
buildNotificationRecord({
id: '20000000-0000-4000-8000-000000000001',
recipient_employee_id: 'employee-1',
read_at: null,
}),
buildNotificationRecord({
id: '20000000-0000-4000-8000-000000000002',
recipient_employee_id: 'employee-1',
type: 'leave.approved',
read_at: null,
created_at: new Date('2026-03-13T08:00:00.000Z'),
}),
buildNotificationRecord({
id: '20000000-0000-4000-8000-000000000003',
recipient_employee_id: 'employee-1',
read_at: new Date('2026-03-13T07:00:00.000Z'),
}),
buildNotificationRecord({
id: '20000000-0000-4000-8000-000000000004',
recipient_employee_id: 'employee-2',
read_at: null,
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'POST',
url: '/api/notifications/mark-all-read',
headers: { authorization: 'Bearer test-token' },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({ updatedCount: 2 });
expect(stateNotifications.filter((item) => item.recipient_employee_id === 'employee-1' && item.read_at === null)).toHaveLength(0);
expect(
stateNotifications.find((item) => item.id === '20000000-0000-4000-8000-000000000004')?.read_at,
).toBeNull();
await app.close();
});
it('keeps mark-all-read idempotent when there are no unread notifications', async () => {
const { prisma } = createPrismaState({
notifications: [
buildNotificationRecord({
id: '30000000-0000-4000-8000-000000000001',
recipient_employee_id: 'employee-1',
read_at: new Date('2026-03-13T07:00:00.000Z'),
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'POST',
url: '/api/notifications/mark-all-read',
headers: { authorization: 'Bearer test-token' },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toEqual({ updatedCount: 0 });
await app.close();
});
});
describe('notification business error reasons', () => {
it.each([
['GET', '/notifications?page=0', undefined, 'notification.list_query_invalid', 'page'],
['GET', '/notifications/deliveries?limit=0', undefined, 'notification.delivery_query_invalid', 'limit'],
['PATCH', '/notifications/channels/not-an-id', { name: '合成渠道' }, 'notification.id_invalid', null],
['POST', '/notifications/channels', { channelType: 'wecom', name: '' }, 'notification.channel_input_invalid', 'name'],
])('keeps %s %s errors precise and side-effect-free', async (method, url, payload, reasonCode, field) => {
const { prisma, auditEntries, stateChannels } = createPrismaState();
const app = await buildApp({ prisma });
try {
const response = await app.inject({ method, url: `/api${url}`, headers: { authorization: 'Bearer synthetic' }, ...(payload ? { payload } : {}) });
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe('VALID_001');
expect(response.json().error.details.reasonCode).toBe(reasonCode);
if (field) expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0);
else expect(response.json().error.details.id).toBe('invalid_uuid');
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
expect(prisma.notificationChannel.update).not.toHaveBeenCalled();
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(prisma.notificationDelivery.findMany).not.toHaveBeenCalled();
expect(stateChannels).toEqual([]);
expect(auditEntries).toEqual([]);
} finally { await app.close(); }
});
it('keeps duplicate channel conflict and has no successful audit', async () => {
const channel = buildChannelRecord({ name: '合成重复渠道' });
const { prisma, stateChannels, auditEntries } = createPrismaState({ channels: [channel] });
const app = await buildApp({ prisma });
try {
const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: channel.name, configJson: { mode: 'mock' } } });
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toEqual({ reasonCode: 'notification.channel_name_duplicate', name: 'duplicate_channel_name' });
expect(stateChannels).toHaveLength(1);
expect(auditEntries).toEqual([]);
} finally { await app.close(); }
});
it('reports missing encryption capability without exposing the submitted synthetic secret', async () => {
const { prisma, auditEntries } = createPrismaState();
const app = await buildApp({ prisma });
const secret = 'synthetic-do-not-echo';
try {
const response = await app.inject({ method: 'POST', url: '/api/notifications/channels', headers: { authorization: 'Bearer synthetic' }, payload: { channelType: 'wecom', name: '合成渠道', configJson: { mode: 'wecom_webhook', webhookUrl: 'https://example.invalid/synthetic', secret } } });
expect(response.statusCode).toBe(400);
expect(response.json().error.details).toEqual({ reasonCode: 'notification.encryption_key_unavailable', secret: 'field_encryption_key_missing' });
expect(response.body.includes(secret)).toBe(false);
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
expect(auditEntries).toEqual([]);
} finally { await app.close(); }
});
});