878 行
27 KiB
JavaScript
878 行
27 KiB
JavaScript
const Fastify = require('fastify');
|
|
|
|
const { AppError } = require('../../../src/utils/AppError');
|
|
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'],
|
|
allowedTypes: ['workflow.task_assigned'],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.channel.configJson).toEqual(
|
|
expect.objectContaining({
|
|
mode: 'wecom_app_message',
|
|
webhookUrl: null,
|
|
secretConfigured: false,
|
|
secretMasked: null,
|
|
}),
|
|
);
|
|
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();
|
|
});
|
|
});
|