R63 A1-A6:通知中心体验增强

这个提交包含在:
2026-04-14 14:14:10 +08:00
父节点 c90cb165be
当前提交 86644a6766
共修改 7 个文件,包含 570 行新增和 62 行删除
+217 -1
查看文件
@@ -68,9 +68,39 @@ function buildDeliveryRecord(overrides = {}) {
};
}
function createPrismaState({ channels = [], deliveries = [] } = {}) {
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 = {
@@ -151,8 +181,60 @@ function createPrismaState({ channels = [], deliveries = [] } = {}) {
}),
};
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 }) => {
@@ -180,8 +262,10 @@ function createPrismaState({ channels = [], deliveries = [] } = {}) {
return { id: `audit-${auditEntries.length}` };
}),
},
$transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)),
},
stateChannels,
stateNotifications,
auditEntries,
};
}
@@ -576,3 +660,135 @@ describe('notifications channel routes', () => {
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();
});
});