feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
edc193f128
commit
b912d29166
54 files changed
+5300
-14
No files matched your search
@@ -330,6 +330,163 @@ describe('NotificationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordForPermissionOnce', () => {
|
||||
const input = {
|
||||
priority: 'HINWEIS' as const,
|
||||
category: 'COMPLIANCE' as const,
|
||||
message: 'Bestand geändert',
|
||||
relatedType: 'ComplianceItem',
|
||||
relatedId: 'herd-change',
|
||||
link: '/compliance',
|
||||
};
|
||||
let calls: string[];
|
||||
let tx: {
|
||||
$executeRaw: jest.Mock;
|
||||
user: { findMany: jest.Mock };
|
||||
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
tx = {
|
||||
$executeRaw: jest.fn().mockImplementation(() => {
|
||||
calls.push('lock');
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
user: {
|
||||
// Like the database: the id filter narrows the three eligible users.
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
({ where }: { where: { id?: { in: string[] } } }) =>
|
||||
Promise.resolve(
|
||||
['u1', 'u2', 'u3']
|
||||
.filter((id) => !where.id || where.id.in.includes(id))
|
||||
.map((id) => ({ id })),
|
||||
),
|
||||
),
|
||||
},
|
||||
notification: {
|
||||
findMany: jest.fn().mockImplementation(() => {
|
||||
calls.push('unread');
|
||||
return Promise.resolve([{ userId: 'u2' }]);
|
||||
}),
|
||||
createManyAndReturn: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: CreateManyArg) =>
|
||||
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('skips users with an unread notification of the same topic', async () => {
|
||||
const rows = await service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
|
||||
'u1',
|
||||
'u3',
|
||||
]);
|
||||
expect(tx.notification.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
relatedType: 'ComplianceItem',
|
||||
relatedId: 'herd-change',
|
||||
read: false,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes again after the user read the earlier one', async () => {
|
||||
tx.notification.findMany.mockResolvedValue([]);
|
||||
|
||||
const rows = await service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('takes the advisory lock before reading the unread rows', async () => {
|
||||
await service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(['lock', 'unread']);
|
||||
});
|
||||
|
||||
it('stores no dedupe key and the recipient permission', async () => {
|
||||
await service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
);
|
||||
|
||||
const [arg] = tx.notification.createManyAndReturn.mock.calls[0] as [
|
||||
CreateManyArg,
|
||||
];
|
||||
expect(arg.data.map((row) => row.dedupeKey)).toEqual([null, null]);
|
||||
expect(arg.data[0].requiredPermissions).toEqual(['compliance:manage']);
|
||||
});
|
||||
|
||||
it('plans the Telegram delivery for the new rows only', async () => {
|
||||
const rows = await service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledTimes(1);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(tx, rows);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('writes nothing and plans nothing when everybody has an unread one', async () => {
|
||||
tx.notification.findMany.mockResolvedValue([
|
||||
{ userId: 'u1' },
|
||||
{ userId: 'u2' },
|
||||
{ userId: 'u3' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.recordForPermissionOnce(
|
||||
'compliance:manage',
|
||||
input,
|
||||
tx as never,
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
expect(tx.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
expect(planner.planIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an empty permission list instead of broadcasting', async () => {
|
||||
await expect(
|
||||
service.recordForPermissionOnce([], input, tx as never),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('recordForPermission is unchanged: it never reads unread rows', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
await service.recordForPermission('compliance:manage', {
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
});
|
||||
|
||||
expect(prisma.notification.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('recordForUsers', () => {
|
||||
it('skips users missing a required permission', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
|
||||
|
||||
Reference in new issue
Block a user