R63 A1-A6:通知中心体验增强
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户