- 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]>
478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { Test } from '@nestjs/testing';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { berlinToday, dayToDate } from '../src/common/dates';
|
|
import type {
|
|
ReadinessItem,
|
|
ReadinessReport,
|
|
ReadinessRule,
|
|
} from '../src/compliance/readiness/readiness.types';
|
|
import {
|
|
READINESS_RULES,
|
|
READINESS_RULES_TOKEN,
|
|
} from '../src/compliance/readiness/rules';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApp } from '../src/app.config';
|
|
import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { login } from './utils/api';
|
|
|
|
const READINESS = '/api/v1/compliance/readiness';
|
|
const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements';
|
|
const SOFT_KEY = 'e2e-soft-check';
|
|
|
|
interface SiteFields {
|
|
holderName: string | null;
|
|
holderAddress: string | null;
|
|
foodBusinessName: string | null;
|
|
foodBusinessAddress: string | null;
|
|
}
|
|
|
|
describe('Compliance readiness (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = Date.now();
|
|
const password = 'SuperSecret123!';
|
|
const tokens: Record<string, string> = {};
|
|
let apiaryOnlyRoleId: string;
|
|
let apiaryId: string;
|
|
let keyCounter = 0;
|
|
let softValue = 1;
|
|
let originalSite: SiteFields;
|
|
let originalVetOffice: string | null | undefined;
|
|
// Only rows this file created are ever deleted (the database is shared).
|
|
const noticeIds: string[] = [];
|
|
const restrictionIds: string[] = [];
|
|
const acknowledgementIds: string[] = [];
|
|
|
|
// A rule that can be acknowledged: the real ones of this task cannot, the
|
|
// later ones will. It gives the acknowledgement flow a real database.
|
|
const softRule: ReadinessRule = {
|
|
key: SOFT_KEY,
|
|
area: 'ETIKETTEN',
|
|
severity: 'PRUEFEN',
|
|
title: 'Testpunkt',
|
|
legalBasis: null,
|
|
acknowledgeable: true,
|
|
evaluate: () => [
|
|
{
|
|
subjectId: 'global',
|
|
detail: 'Bitte prüfen.',
|
|
fingerprintParts: { value: softValue },
|
|
subjects: [],
|
|
},
|
|
],
|
|
};
|
|
|
|
const server = () => app.getHttpServer();
|
|
const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` });
|
|
const nextKey = () =>
|
|
`readiness-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
|
|
|
|
async function report(user = 'beekeeper'): Promise<ReadinessReport> {
|
|
const response = await request(server()).get(READINESS).set(as(user));
|
|
expect(response.status).toBe(200);
|
|
return response.body as ReadinessReport;
|
|
}
|
|
|
|
function itemOf(
|
|
body: ReadinessReport,
|
|
key: string,
|
|
subjectId = 'global',
|
|
): ReadinessItem {
|
|
const found = body.items.find(
|
|
(item) => item.key === key && item.subjectId === subjectId,
|
|
);
|
|
if (!found) {
|
|
throw new Error(`No readiness item ${key}/${subjectId}`);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function acknowledge(body: Record<string, string>, user = 'beekeeper') {
|
|
return request(server())
|
|
.post(ACKNOWLEDGEMENTS)
|
|
.set(as(user))
|
|
.set('Idempotency-Key', nextKey())
|
|
.send(body);
|
|
}
|
|
|
|
async function createUser(
|
|
name: string,
|
|
roleWhere: { key: string } | { id: string },
|
|
) {
|
|
const email = `readiness-e2e-${name}-${suffix}@example.com`;
|
|
await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, 12),
|
|
name,
|
|
role: { connect: roleWhere },
|
|
},
|
|
});
|
|
tokens[name] = (await login(app, email, password)).accessToken;
|
|
}
|
|
|
|
// Settings rows are saved in beforeAll and put back in afterAll, never
|
|
// deleted: other suites rely on the seeded rows.
|
|
async function setSite(data: SiteFields) {
|
|
await prisma.siteSettings.upsert({
|
|
where: { id: 'singleton' },
|
|
update: data,
|
|
create: { id: 'singleton', ...data },
|
|
});
|
|
}
|
|
|
|
async function setVetOffice(name: string | null) {
|
|
await prisma.complianceSettings.upsert({
|
|
where: { id: 'singleton' },
|
|
update: { veterinaryOfficeName: name },
|
|
create: { id: 'singleton', veterinaryOfficeName: name },
|
|
});
|
|
}
|
|
|
|
async function removeOwnRows() {
|
|
await prisma.complianceAcknowledgement.deleteMany({
|
|
where: { id: { in: acknowledgementIds } },
|
|
});
|
|
await prisma.authorityNotice.deleteMany({
|
|
where: { id: { in: noticeIds } },
|
|
});
|
|
await prisma.apiaryRestriction.deleteMany({
|
|
where: { id: { in: restrictionIds } },
|
|
});
|
|
noticeIds.length = 0;
|
|
restrictionIds.length = 0;
|
|
acknowledgementIds.length = 0;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// The module gets the soft test rule through its token; the shared list
|
|
// stays untouched.
|
|
const moduleFixture = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(THROTTLER_NAMESPACE)
|
|
.useValue(randomUUID())
|
|
.overrideProvider(READINESS_RULES_TOKEN)
|
|
.useValue([...READINESS_RULES, softRule])
|
|
.compile();
|
|
const nest = moduleFixture.createNestApplication<NestExpressApplication>();
|
|
configureApp(nest);
|
|
await nest.init();
|
|
app = nest;
|
|
prisma = app.get(PrismaService);
|
|
originalVetOffice = (
|
|
await prisma.complianceSettings.findUnique({ where: { id: 'singleton' } })
|
|
)?.veterinaryOfficeName;
|
|
const site = await prisma.siteSettings.findUnique({
|
|
where: { id: 'singleton' },
|
|
});
|
|
originalSite = {
|
|
holderName: site?.holderName ?? null,
|
|
holderAddress: site?.holderAddress ?? null,
|
|
foodBusinessName: site?.foodBusinessName ?? null,
|
|
foodBusinessAddress: site?.foodBusinessAddress ?? null,
|
|
};
|
|
await createUser('beekeeper', { key: 'BEEKEEPER' });
|
|
await createUser('staff', { key: 'STAFF' });
|
|
await createUser('readonly', { key: 'READONLY' });
|
|
await createUser('apiclient', { key: 'API_CLIENT' });
|
|
// compliance:read only, without apiaries:read.
|
|
const role = await prisma.role.create({
|
|
data: { key: `READINESS_E2E_${suffix}`, name: 'Nur Prüfbereitschaft' },
|
|
});
|
|
apiaryOnlyRoleId = role.id;
|
|
const permission = await prisma.permission.findFirstOrThrow({
|
|
where: { key: 'compliance:read' },
|
|
});
|
|
await prisma.rolePermission.create({
|
|
data: { roleId: role.id, permissionId: permission.id },
|
|
});
|
|
await createUser('complianceonly', { id: role.id });
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Readiness Stand ${suffix}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await removeOwnRows();
|
|
softValue = 1;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await removeOwnRows();
|
|
await setSite(originalSite);
|
|
if (originalVetOffice !== undefined) {
|
|
await setVetOffice(originalVetOffice);
|
|
}
|
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
|
const mine = {
|
|
email: {
|
|
startsWith: 'readiness-e2e-',
|
|
endsWith: `-${suffix}@example.com`,
|
|
},
|
|
};
|
|
const userIds = (
|
|
await prisma.user.findMany({ where: mine, select: { id: true } })
|
|
).map((user) => user.id);
|
|
await prisma.refreshToken.deleteMany({
|
|
where: { userId: { in: userIds } },
|
|
});
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
await prisma.rolePermission.deleteMany({
|
|
where: { roleId: apiaryOnlyRoleId },
|
|
});
|
|
await prisma.role.deleteMany({ where: { id: apiaryOnlyRoleId } });
|
|
await app.close();
|
|
});
|
|
|
|
it('without site data and without a SENT registration the Stammdaten and registration items are open and the status is OPEN', async () => {
|
|
await setSite({
|
|
holderName: null,
|
|
holderAddress: null,
|
|
foodBusinessName: null,
|
|
foodBusinessAddress: null,
|
|
});
|
|
await setVetOffice(null);
|
|
// Rows of other suites stay: the registration follows what exists.
|
|
const sentRegistrations = await prisma.authorityNotice.count({
|
|
where: { kind: 'REGISTRATION', status: 'SENT' },
|
|
});
|
|
|
|
const body = await report();
|
|
|
|
for (const key of ['holder-data', 'food-business-data']) {
|
|
expect(itemOf(body, key)).toMatchObject({ state: 'OPEN' });
|
|
}
|
|
expect(itemOf(body, 'registration').state).toBe(
|
|
sentRegistrations === 0 ? 'OPEN' : 'DONE',
|
|
);
|
|
expect(itemOf(body, 'holder-data').subjects[0].missing).toEqual([
|
|
'Name',
|
|
'Anschrift',
|
|
]);
|
|
expect(itemOf(body, 'vet-office').state).toBe('OPEN');
|
|
expect(body.status).toBe('OPEN');
|
|
expect(body.openCount).toBeGreaterThanOrEqual(3);
|
|
expect(Number.isNaN(Date.parse(body.generatedAt))).toBe(false);
|
|
|
|
const summary = await request(server())
|
|
.get(`${READINESS}/summary`)
|
|
.set(as('beekeeper'));
|
|
expect(summary.status).toBe(200);
|
|
expect(summary.body).toMatchObject({ status: 'OPEN' });
|
|
expect((summary.body as { firstItemKey: string }).firstItemKey).toEqual(
|
|
expect.any(String),
|
|
);
|
|
});
|
|
|
|
it('complete master data and a SENT registration make those items DONE', async () => {
|
|
await setSite({
|
|
holderName: 'Imker E2E',
|
|
holderAddress: 'Weg 1, 12345 Ort',
|
|
foodBusinessName: 'Imkerei E2E',
|
|
foodBusinessAddress: 'Weg 2, 12345 Ort',
|
|
});
|
|
await setVetOffice('Amt E2E');
|
|
const sent = await prisma.authorityNotice.create({
|
|
data: {
|
|
kind: 'REGISTRATION',
|
|
status: 'SENT',
|
|
referenceDate: dayToDate('2026-01-01'),
|
|
countedAt: new Date('2026-01-01T10:00:00.000Z'),
|
|
snapshot: { version: 1, takenAt: 'x', apiaries: [], totalLiving: 0 },
|
|
totalColonies: 0,
|
|
sentOn: dayToDate('2026-01-02'),
|
|
},
|
|
});
|
|
noticeIds.push(sent.id);
|
|
|
|
const body = await report();
|
|
|
|
for (const key of [
|
|
'holder-data',
|
|
'food-business-data',
|
|
'vet-office',
|
|
'registration',
|
|
]) {
|
|
expect(itemOf(body, key)).toMatchObject({
|
|
state: 'DONE',
|
|
subjects: [],
|
|
more: 0,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', async () => {
|
|
const payload = {
|
|
itemKey: SOFT_KEY,
|
|
subjectId: 'global',
|
|
fingerprint: 'a'.repeat(64),
|
|
note: 'Geprüft und in Ordnung.',
|
|
};
|
|
|
|
expect((await report('readonly')).items.length).toBeGreaterThan(0);
|
|
expect((await report('staff')).items.length).toBeGreaterThan(0);
|
|
expect((await acknowledge(payload, 'readonly')).status).toBe(403);
|
|
expect((await acknowledge(payload, 'staff')).status).toBe(403);
|
|
expect(
|
|
(
|
|
await request(server())
|
|
.post(`${ACKNOWLEDGEMENTS}/someid/revoke`)
|
|
.set(as('staff'))
|
|
).status,
|
|
).toBe(403);
|
|
for (const path of [READINESS, `${READINESS}/summary`]) {
|
|
expect(
|
|
(await request(server()).get(path).set(as('apiclient'))).status,
|
|
).toBe(403);
|
|
}
|
|
expect((await acknowledge(payload, 'apiclient')).status).toBe(403);
|
|
expect((await request(server()).get(READINESS)).status).toBe(401);
|
|
});
|
|
|
|
it('shows the apiaries of an active restriction only with apiaries:read, but counts them for everyone', async () => {
|
|
const created = await prisma.apiaryRestriction.create({
|
|
data: {
|
|
apiaryId,
|
|
disease: 'Faulbrut',
|
|
authority: 'Amt Geheim',
|
|
startsOn: dayToDate(berlinToday()),
|
|
},
|
|
});
|
|
restrictionIds.push(created.id);
|
|
|
|
const full = itemOf(await report('beekeeper'), 'restriction-active');
|
|
const masked = itemOf(await report('complianceonly'), 'restriction-active');
|
|
|
|
expect(full.state).toBe('OPEN');
|
|
expect(full.subjects.map((subject) => subject.id)).toContain(apiaryId);
|
|
expect(masked.state).toBe('OPEN');
|
|
expect(masked.subjects).toEqual([]);
|
|
expect(masked.more).toBeGreaterThanOrEqual(1);
|
|
expect(JSON.stringify(masked)).not.toContain('Readiness Stand');
|
|
expect(JSON.stringify(masked)).not.toContain('Amt Geheim');
|
|
expect(JSON.stringify(full)).not.toContain('Amt Geheim');
|
|
});
|
|
|
|
it('an ended restriction does not keep the item open', async () => {
|
|
const ended = await prisma.apiaryRestriction.create({
|
|
data: {
|
|
apiaryId,
|
|
disease: 'Faulbrut',
|
|
startsOn: dayToDate('2020-01-01'),
|
|
endsOn: dayToDate('2020-02-01'),
|
|
},
|
|
});
|
|
restrictionIds.push(ended.id);
|
|
|
|
const body = await report();
|
|
|
|
expect(
|
|
body.items
|
|
.find((item) => item.key === 'restriction-active')
|
|
?.subjects.map((subject) => subject.id) ?? [],
|
|
).not.toContain(apiaryId);
|
|
});
|
|
|
|
describe('acknowledgements', () => {
|
|
it('refuses a PFLICHT item, an unknown item and a short note with 400', async () => {
|
|
const body = await report();
|
|
const holder = itemOf(body, 'holder-data');
|
|
const soft = itemOf(body, SOFT_KEY);
|
|
|
|
const pflicht = await acknowledge({
|
|
itemKey: 'holder-data',
|
|
subjectId: 'global',
|
|
fingerprint: holder.fingerprint,
|
|
note: 'Geprüft und in Ordnung.',
|
|
});
|
|
const unknown = await acknowledge({
|
|
itemKey: 'no-such-item',
|
|
subjectId: 'global',
|
|
fingerprint: soft.fingerprint,
|
|
note: 'Geprüft und in Ordnung.',
|
|
});
|
|
const shortNote = await acknowledge({
|
|
itemKey: SOFT_KEY,
|
|
subjectId: 'global',
|
|
fingerprint: soft.fingerprint,
|
|
note: 'kurz',
|
|
});
|
|
|
|
expect(pflicht.status).toBe(400);
|
|
expect(unknown.status).toBe(400);
|
|
expect(shortNote.status).toBe(400);
|
|
});
|
|
|
|
it('acknowledges with the current fingerprint, reopens on changed data, and a revoke reopens it', async () => {
|
|
const before = itemOf(await report(), SOFT_KEY);
|
|
|
|
const stale = await acknowledge({
|
|
itemKey: SOFT_KEY,
|
|
subjectId: 'global',
|
|
fingerprint: 'b'.repeat(64),
|
|
note: 'Geprüft und in Ordnung.',
|
|
});
|
|
const created = await acknowledge({
|
|
itemKey: SOFT_KEY,
|
|
subjectId: 'global',
|
|
fingerprint: before.fingerprint,
|
|
note: 'Geprüft und in Ordnung.',
|
|
});
|
|
|
|
expect(stale.status).toBe(409);
|
|
expect(created.status).toBe(201);
|
|
const createdItem = created.body as ReadinessItem;
|
|
expect(createdItem.state).toBe('ACKNOWLEDGED');
|
|
const acknowledgementId = createdItem.acknowledgement!.id;
|
|
acknowledgementIds.push(acknowledgementId);
|
|
expect(itemOf(await report(), SOFT_KEY)).toMatchObject({
|
|
state: 'ACKNOWLEDGED',
|
|
acknowledgement: { id: acknowledgementId },
|
|
});
|
|
expect(
|
|
await prisma.auditLog.count({
|
|
where: {
|
|
entityType: 'ComplianceAcknowledgement',
|
|
entityId: acknowledgementId,
|
|
action: 'CREATE',
|
|
},
|
|
}),
|
|
).toBe(1);
|
|
|
|
softValue = 2;
|
|
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
|
|
softValue = 1;
|
|
expect(itemOf(await report(), SOFT_KEY).state).toBe('ACKNOWLEDGED');
|
|
|
|
const revoke = () =>
|
|
request(server())
|
|
.post(`${ACKNOWLEDGEMENTS}/${acknowledgementId}/revoke`)
|
|
.set(as('beekeeper'));
|
|
expect((await revoke()).status).toBe(201);
|
|
expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN');
|
|
expect((await revoke()).status).toBe(409);
|
|
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
|
|
where: { id: acknowledgementId },
|
|
});
|
|
expect(row.revokedAt).not.toBeNull();
|
|
expect(row.revokedByUserId).not.toBeNull();
|
|
});
|
|
|
|
it('revoking an unknown acknowledgement is a 404', async () => {
|
|
const response = await request(server())
|
|
.post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`)
|
|
.set(as('beekeeper'));
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
});
|
|
});
|