- 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]>
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { berlinToday } from '../src/common/dates';
|
|
import { findBaselineIn, herdStatusIn } from '../src/compliance/herd-status';
|
|
import { NotificationsService } from '../src/notifications/notifications.service';
|
|
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { login } from './utils/api';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
|
|
interface NoticeBody {
|
|
id: string;
|
|
totalColonies: number;
|
|
}
|
|
|
|
interface ColonyBody {
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* The herd-change notification (T6 task 11): one unread notification per
|
|
* permitted user and topic. "Permitted" are the users this file creates with
|
|
* compliance:manage; other users of the shared test database do not count.
|
|
*/
|
|
describe('Authority notices: herd change notification (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = Date.now();
|
|
const password = 'SuperSecret123!';
|
|
const managerEmails = [
|
|
`herd-e2e-manager-a-${suffix}@example.com`,
|
|
`herd-e2e-manager-b-${suffix}@example.com`,
|
|
];
|
|
const staffEmail = `herd-e2e-staff-${suffix}@example.com`;
|
|
let managerIds: string[];
|
|
let staffId: string;
|
|
let managerToken: string;
|
|
let apiaryId: string;
|
|
let keyCounter = 0;
|
|
const colonyIds: string[] = [];
|
|
/** The notices this file created: the only ones it deletes. */
|
|
const noticeIds: string[] = [];
|
|
/** The shared settings row as found; restored in afterAll. */
|
|
let savedSettings: {
|
|
noticeOnColonyCountChange: boolean;
|
|
noticeOnApiaryChange: boolean;
|
|
};
|
|
const ONCE_TOPIC = `herd-e2e-once-${suffix}`;
|
|
|
|
const server = () => app.getHttpServer();
|
|
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
|
|
const nextKey = () =>
|
|
`herd-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const create = (email: string, roleKey: string) =>
|
|
prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash,
|
|
name: roleKey,
|
|
role: { connect: { key: roleKey } },
|
|
},
|
|
});
|
|
managerIds = [];
|
|
for (const email of managerEmails) {
|
|
managerIds.push((await create(email, 'BEEKEEPER')).id);
|
|
}
|
|
staffId = (await create(staffEmail, 'STAFF')).id;
|
|
const settings = await prisma.complianceSettings.upsert({
|
|
where: { id: 'singleton' },
|
|
update: {},
|
|
create: { id: 'singleton' },
|
|
});
|
|
savedSettings = {
|
|
noticeOnColonyCountChange: settings.noticeOnColonyCountChange,
|
|
noticeOnApiaryChange: settings.noticeOnApiaryChange,
|
|
};
|
|
managerToken = (await login(app, managerEmails[0], password)).accessToken;
|
|
apiaryId = (
|
|
await prisma.apiary.create({ data: { name: `Herd Stand ${suffix}` } })
|
|
).id;
|
|
colonyIds.push(
|
|
(
|
|
await prisma.colony.create({
|
|
data: {
|
|
code: `H-${suffix}-0`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
})
|
|
).id,
|
|
);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await prisma.complianceSettings.update({
|
|
where: { id: 'singleton' },
|
|
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
|
|
});
|
|
await prisma.notification.deleteMany({
|
|
where: { userId: { in: [...managerIds, staffId] } },
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// The shared database holds other suites' notices: only own ids go.
|
|
await prisma.authorityNotice.deleteMany({
|
|
where: { id: { in: noticeIds.splice(0) } },
|
|
});
|
|
await prisma.notification.deleteMany({
|
|
where: { relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC },
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.complianceSettings.update({
|
|
where: { id: 'singleton' },
|
|
data: savedSettings,
|
|
});
|
|
const userIds = [...managerIds, staffId];
|
|
await prisma.notification.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateId: { in: colonyIds } },
|
|
});
|
|
await prisma.colony.deleteMany({ where: { apiaryId } });
|
|
await prisma.domainEvent.deleteMany({ where: { aggregateId: apiaryId } });
|
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await app.close();
|
|
});
|
|
|
|
/** A notice sent today is the baseline: the current herd. */
|
|
async function sendBaseline(): Promise<void> {
|
|
const kind = (await findBaselineIn(prisma))
|
|
? 'HERD_CHANGE'
|
|
: 'REGISTRATION';
|
|
const response = await request(server())
|
|
.post('/api/v1/authority-notices')
|
|
.set(as(managerToken))
|
|
.set('Idempotency-Key', nextKey())
|
|
.send({ kind, sentOn: berlinToday() })
|
|
.expect(201);
|
|
noticeIds.push((response.body as NoticeBody).id);
|
|
}
|
|
|
|
async function addColony(): Promise<string> {
|
|
const response = await request(server())
|
|
.post('/api/v1/colonies')
|
|
.set(as(managerToken))
|
|
.send({
|
|
code: `H-${suffix}-${colonyIds.length}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
})
|
|
.expect(201);
|
|
const { id } = response.body as ColonyBody;
|
|
colonyIds.push(id);
|
|
await drainDomainEvents(app, [id]);
|
|
return id;
|
|
}
|
|
|
|
const unreadOf = (userIds: string[]) =>
|
|
prisma.notification.findMany({
|
|
where: {
|
|
userId: { in: userIds },
|
|
relatedType: 'ComplianceItem',
|
|
relatedId: 'herd-change',
|
|
read: false,
|
|
},
|
|
select: { userId: true, category: true, priority: true, message: true },
|
|
});
|
|
|
|
it('registers both handlers in the API', () => {
|
|
expect(app.get(DomainEventRegistry).handlerIds()).toEqual(
|
|
expect.arrayContaining([
|
|
'compliance.herd-change',
|
|
'compliance.tsk-reference-day',
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('a new colony changes the herd: after draining one unread notification per permitted user', async () => {
|
|
await sendBaseline();
|
|
|
|
await addColony();
|
|
|
|
const unread = await unreadOf(managerIds);
|
|
expect(unread.map((row) => row.userId).sort()).toEqual(
|
|
[...managerIds].sort(),
|
|
);
|
|
expect(unread[0]).toMatchObject({
|
|
category: 'COMPLIANCE',
|
|
priority: 'HINWEIS',
|
|
});
|
|
expect(unread[0].message).toMatch(
|
|
/^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/,
|
|
);
|
|
});
|
|
|
|
it('a second colony within one unread notice adds no second notification', async () => {
|
|
await sendBaseline();
|
|
|
|
await addColony();
|
|
await addColony();
|
|
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
});
|
|
|
|
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', async () => {
|
|
await sendBaseline();
|
|
await addColony();
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
|
|
const created = await request(server())
|
|
.post('/api/v1/authority-notices')
|
|
.set(as(managerToken))
|
|
.set('Idempotency-Key', nextKey())
|
|
.send({ kind: 'HERD_CHANGE' })
|
|
.expect(201);
|
|
const notice = created.body as NoticeBody;
|
|
noticeIds.push(notice.id);
|
|
await request(server())
|
|
.post(`/api/v1/authority-notices/${notice.id}/mark-sent`)
|
|
.set(as(managerToken))
|
|
.send({})
|
|
.expect(201);
|
|
await prisma.notification.updateMany({
|
|
where: { userId: { in: managerIds } },
|
|
data: { read: true },
|
|
});
|
|
|
|
// The sent notice is the new baseline: nothing deviates any more.
|
|
const status = await herdStatusIn(prisma, new Date());
|
|
expect(status.deviation?.triggered).toBe(false);
|
|
expect(await unreadOf(managerIds)).toHaveLength(0);
|
|
|
|
await addColony();
|
|
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
});
|
|
|
|
it('a user without compliance:manage gets no herd notification', async () => {
|
|
await sendBaseline();
|
|
|
|
await addColony();
|
|
|
|
expect(await unreadOf([staffId])).toHaveLength(0);
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
});
|
|
|
|
async function patchApiary(body: Record<string, string>): Promise<void> {
|
|
await request(server())
|
|
.patch(`/api/v1/apiaries/${apiaryId}`)
|
|
.set(as(managerToken))
|
|
.send(body)
|
|
.expect(200);
|
|
await drainDomainEvents(app, [apiaryId]);
|
|
}
|
|
|
|
it('an apiary move (LOCATION_CHANGED) counts', async () => {
|
|
await sendBaseline();
|
|
|
|
await patchApiary({ location: `Neuer Standort ${suffix}` });
|
|
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
});
|
|
|
|
it('a rename does not count', async () => {
|
|
await sendBaseline();
|
|
|
|
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
|
|
|
|
expect(await unreadOf(managerIds)).toHaveLength(0);
|
|
});
|
|
|
|
it('noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => {
|
|
await prisma.complianceSettings.update({
|
|
where: { id: 'singleton' },
|
|
data: { noticeOnColonyCountChange: false },
|
|
});
|
|
await sendBaseline();
|
|
|
|
await addColony();
|
|
expect(await unreadOf(managerIds)).toHaveLength(0);
|
|
|
|
await patchApiary({ location: `Ort wieder neu ${suffix}` });
|
|
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
|
|
});
|
|
|
|
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
|
|
const notifications = app.get(NotificationsService);
|
|
const once = () =>
|
|
prisma.$transaction((tx) =>
|
|
notifications.recordForPermissionOnce(
|
|
'compliance:manage',
|
|
{
|
|
priority: 'INFO',
|
|
category: 'COMPLIANCE',
|
|
message: 'Parallel',
|
|
relatedType: 'ComplianceItem',
|
|
relatedId: ONCE_TOPIC,
|
|
link: '/compliance',
|
|
},
|
|
tx,
|
|
),
|
|
);
|
|
|
|
await Promise.all([once(), once()]);
|
|
|
|
const rows = await prisma.notification.groupBy({
|
|
by: ['userId'],
|
|
where: {
|
|
relatedType: 'ComplianceItem',
|
|
relatedId: ONCE_TOPIC,
|
|
read: false,
|
|
},
|
|
_count: { _all: true },
|
|
});
|
|
expect(rows.length).toBeGreaterThanOrEqual(managerIds.length);
|
|
for (const row of rows) {
|
|
expect(row._count._all).toBe(1);
|
|
}
|
|
expect(rows.map((row) => row.userId)).toEqual(
|
|
expect.arrayContaining(managerIds),
|
|
);
|
|
expect(rows.map((row) => row.userId)).not.toContain(staffId);
|
|
});
|
|
});
|