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
@@ -0,0 +1,340 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -373,4 +373,56 @@ describe('Authority notices (e2e)', () => {
|
||||
.send({ tskReferenceDay: '01-01' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('GET /authority-notices/:id/pdf answers application/pdf with Cache-Control no-store and the file name anzeige-<id>.pdf', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ kind: 'TSK_REPORT', year: 2027 })
|
||||
.expect(201);
|
||||
const id = (created.body as NoticeBody).id;
|
||||
|
||||
const response = await request(server())
|
||||
.get(`/api/v1/authority-notices/${id}/pdf`)
|
||||
.set(as(staffToken))
|
||||
.buffer(true)
|
||||
.parse((res, callback) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => callback(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers['content-type']).toContain('application/pdf');
|
||||
expect(response.headers['cache-control']).toContain('no-store');
|
||||
expect(response.headers['content-disposition']).toBe(
|
||||
`attachment; filename="anzeige-${id}.pdf"`,
|
||||
);
|
||||
expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
|
||||
'%PDF',
|
||||
);
|
||||
await request(server())
|
||||
.get('/api/v1/authority-notices/does-not-exist/pdf')
|
||||
.set(as(staffToken))
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('the pdf route needs compliance:read', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/authority-notices')
|
||||
.set(as(beekeeperToken))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send({ kind: 'REGISTRATION' })
|
||||
.expect(201);
|
||||
const id = (created.body as NoticeBody).id;
|
||||
|
||||
await request(server())
|
||||
.get(`/api/v1/authority-notices/${id}/pdf`)
|
||||
.set(as(noRightsToken))
|
||||
.expect(403);
|
||||
await request(server())
|
||||
.get(`/api/v1/authority-notices/${id}/pdf`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,13 @@ import { Prisma } from '../generated/prisma';
|
||||
import { AutopilotStep } from '../src/autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { ComplianceModule } from '../src/compliance/compliance.module';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { PhenologyStep } from '../src/phenology/phenology.step';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../src/system/job-run.service';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
@@ -18,6 +20,7 @@ const NOTHING_NEW = {
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
TskReferenceDayReached: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -54,7 +57,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [JobsModule],
|
||||
// The compliance handlers turn the TSK reference day into a draft.
|
||||
imports: [JobsModule, ComplianceModule],
|
||||
}).compile();
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
@@ -239,6 +243,107 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
||||
});
|
||||
|
||||
describe('TSK reference day', () => {
|
||||
let savedReferenceDay: string;
|
||||
let permittedUsers: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The seeded singleton stays: only its reference day is borrowed.
|
||||
const saved = await prisma.complianceSettings.upsert({
|
||||
where: { id: 'singleton' },
|
||||
update: {},
|
||||
create: { id: 'singleton' },
|
||||
});
|
||||
savedReferenceDay = saved.tskReferenceDay;
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: { tskReferenceDay: '03-03' },
|
||||
});
|
||||
permittedUsers = await prisma.user.count({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: { some: { permission: { key: 'compliance:manage' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await prisma.notification.deleteMany({
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
|
||||
});
|
||||
await prisma.authorityNotice.deleteMany({ where: { year: 2001 } });
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: 'tsk:2001' },
|
||||
});
|
||||
await prisma.complianceSettings.update({
|
||||
where: { id: 'singleton' },
|
||||
data: { tskReferenceDay: savedReferenceDay },
|
||||
});
|
||||
});
|
||||
|
||||
async function expectOneDraftAndNotices(): Promise<void> {
|
||||
expect(await eventsOf('tsk:2001')).toEqual([
|
||||
{
|
||||
type: 'TskReferenceDayReached',
|
||||
aggregateType: 'Compliance',
|
||||
payload: { year: 2001, referenceDay: '2001-03-03' },
|
||||
dedupeKey: 'TskReferenceDayReached:tsk:2001:2001-03-03',
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
const drafts = await prisma.authorityNotice.findMany({
|
||||
where: { kind: 'TSK_REPORT', year: 2001 },
|
||||
});
|
||||
expect(drafts).toHaveLength(1);
|
||||
expect(drafts[0]).toMatchObject({
|
||||
status: 'DRAFT',
|
||||
openKey: 'TSK_REPORT:2001',
|
||||
});
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
|
||||
select: { userId: true, priority: true, category: true, message: true },
|
||||
});
|
||||
expect(notifications).toHaveLength(permittedUsers);
|
||||
expect(new Set(notifications.map((row) => row.userId)).size).toBe(
|
||||
permittedUsers,
|
||||
);
|
||||
for (const row of notifications) {
|
||||
expect(row).toMatchObject({
|
||||
priority: 'WARNUNG',
|
||||
category: 'COMPLIANCE',
|
||||
});
|
||||
expect(row.message).toMatch(
|
||||
/^TSK-Stichtag 03\.03\.2001: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('daily evaluation twice on the reference day: one event, one draft', async () => {
|
||||
const first = await step.run('2001-03-03');
|
||||
const second = await step.run('2001-03-03');
|
||||
await drainDomainEvents(app, ['tsk:2001']);
|
||||
await step.run('2001-03-04');
|
||||
await drainDomainEvents(app, ['tsk:2001']);
|
||||
|
||||
expect(first.TskReferenceDayReached).toBe(1);
|
||||
expect(second.TskReferenceDayReached).toBe(0);
|
||||
await expectOneDraftAndNotices();
|
||||
});
|
||||
|
||||
it('catches up a missed reference day on the 5th exactly once', async () => {
|
||||
const catchUp = await step.run('2001-03-05');
|
||||
const again = await step.run('2001-03-05');
|
||||
await drainDomainEvents(app, ['tsk:2001']);
|
||||
|
||||
expect(catchUp.TskReferenceDayReached).toBe(1);
|
||||
expect(again.TskReferenceDayReached).toBe(0);
|
||||
await expectOneDraftAndNotices();
|
||||
});
|
||||
});
|
||||
|
||||
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||
await step.run('2001-05-20');
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DispatchFilter,
|
||||
DomainEventDispatcher,
|
||||
} from '../src/domain-events/domain-event-dispatcher';
|
||||
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
|
||||
import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
|
||||
@@ -217,6 +218,15 @@ describe('Worker runtime (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('registers the compliance handlers in the worker context too', () => {
|
||||
expect(worker.get(DomainEventRegistry).handlerIds()).toEqual(
|
||||
expect.arrayContaining([
|
||||
'compliance.herd-change',
|
||||
'compliance.tsk-reference-day',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {
|
||||
expect(processOutbox).toHaveBeenCalled();
|
||||
const before = processOutbox.mock.calls.length;
|
||||
|
||||
Reference in new issue
Block a user