Files
ApiCore/ApiCore-Backend/test/compliance-readiness.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 b4746a2b17
Backend Image / build-and-push (push) Successful in 6m45s
Frontend Image / build-and-push (push) Failing after 9m30s
feat: T6 wave 5 – readiness checks and report, compliance and medicine UI, label UI
- Readiness: all 18 checks (medicine lots, treatment log gaps, labels,
  harvest withdrawal), medicine subjects masked without
  veterinary-medicines:read, report PDF (counts only), summary cached 60 s,
  409 codes READINESS_*.
- Frontend: /compliance page with acknowledge/revoke, dashboard line and
  nav; veterinary medicine catalogue, lots, movement book with inventory
  and disposal; label designer check panel, print card with gap list and
  proof print, food business master data, product legal name.
- Service worker: cacheability is path-based (the backend sends no-store
  on every answer); compliance, medicine, restriction, notice and label
  check/print paths and restricted document kinds are never cached.
- ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 15:59:08 +02:00

960 lines
32 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 { addDays, berlinToday, dayToDate } from '../src/common/dates';
import {
buildTreatmentLog,
treatmentLogGaps,
} from '../src/export/treatment-log';
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';
import { pdfText } from './utils/pdf-text';
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(stale.body).toMatchObject({
code: 'READINESS_FINGERPRINT_STALE',
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
});
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');
const again = await revoke();
expect(again.status).toBe(409);
expect(again.body).toMatchObject({
code: 'READINESS_ACK_REVOKED',
message: 'Die Quittung ist schon zurückgenommen.',
});
const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({
where: { id: acknowledgementId },
});
expect(row.revokedAt).not.toBeNull();
expect(row.revokedByUserId).not.toBeNull();
});
it('acknowledging a subject that is not open answers 409 READINESS_ITEM_NOT_OPEN', async () => {
const response = await acknowledge({
itemKey: SOFT_KEY,
subjectId: 'no-such-subject',
fingerprint: 'a'.repeat(64),
note: 'Geprüft und in Ordnung.',
});
expect(response.status).toBe(409);
expect(response.body).toMatchObject({
code: 'READINESS_ITEM_NOT_OPEN',
message: 'Der Punkt hat sich geändert. Bitte neu laden.',
});
});
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);
});
});
describe('medicine, label and harvest areas', () => {
const area = Date.now();
const today = berlinToday();
const created = {
treatments: [] as string[],
lots: [] as string[],
harvests: [] as string[],
};
let colonyId: string;
let medicineId: string;
let lotOk: string;
let lotSmall: string;
let lotOld: string;
let treatmentOk: string;
let treatmentSmall: string;
let treatmentOld: string;
let harvestInside: string;
let harvestAfter: string;
let productId: string;
let templateId: string;
let userId: string;
let extractionId: string;
let honeyBatchId: string;
let bottlingId: string;
let medicineName: string;
const textElement = (id: string, y: number, key: string, size = 9) => ({
id,
type: 'text',
x: 2,
y,
width: 80,
height: 8,
text: '',
placeholderKey: key,
fontSize: size,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
});
const itemsOf = (body: ReadinessReport, key: string) =>
body.items.filter((item) => item.key === key);
const openSubjectIds = (body: ReadinessReport, key: string) =>
itemsOf(body, key)
.filter((item) => item.state === 'OPEN')
.flatMap((item) => [
item.subjectId,
...item.subjects.map((subject) => subject.id),
]);
async function postTreatment(body: Record<string, unknown>) {
const response = await request(server())
.post('/api/v1/treatments')
.set(as('beekeeper'))
.send({ colonyId, date: today, ...body });
expect(response.status).toBe(201);
const id = (response.body as { id: string }).id;
created.treatments.push(id);
return id;
}
async function makeLot(patch: Record<string, unknown>) {
const lot = await prisma.veterinaryMedicineLot.create({
data: {
medicineId,
purchasedOn: dayToDate('2026-01-10'),
quantity: 100,
supplier: 'Apotheke E2E',
purchaseProof: 'Beleg E2E',
expiresOn: dayToDate('2099-01-01'),
...patch,
},
});
created.lots.push(lot.id);
return lot.id;
}
beforeAll(async () => {
const owner = await prisma.user.findFirstOrThrow({
where: { email: `readiness-e2e-beekeeper-${suffix}@example.com` },
});
userId = owner.id;
colonyId = (
await prisma.colony.create({
data: {
code: `RA-${area}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
medicineName = `Readiness-Mittel ${area}`;
medicineId = (
await prisma.veterinaryMedicine.create({
data: { name: medicineName, unit: 'ML', withdrawalPeriodDays: 0 },
})
).id;
lotOk = await makeLot({});
lotSmall = await makeLot({ quantity: 10 });
lotOld = await makeLot({
quantity: 5,
expiresOn: dayToDate('2020-01-01'),
});
treatmentOk = await postTreatment({
medicineLotId: lotOk,
doseQuantity: 30,
withdrawalPeriodDays: 0,
});
treatmentSmall = await postTreatment({
medicineLotId: lotSmall,
doseQuantity: 30,
withdrawalPeriodDays: 0,
});
treatmentOld = await postTreatment({
medicineLotId: lotOld,
doseQuantity: 1,
withdrawalPeriodDays: 0,
});
await postTreatment({
product: `Wartezeit-Mittel ${area}`,
amount: '10 ml',
supplier: 'Apotheke E2E',
purchaseProof: 'Beleg E2E',
withdrawalPeriodDays: 10,
});
harvestInside = (
await prisma.harvest.create({
data: { apiaryId, date: dayToDate(today), userId },
})
).id;
harvestAfter = (
await prisma.harvest.create({
data: { apiaryId, date: dayToDate(addDays(today, 20)), userId },
})
).id;
created.harvests.push(harvestInside, harvestAfter);
for (const harvestId of created.harvests) {
await prisma.harvestColony.create({ data: { harvestId, colonyId } });
}
// A complete label: its own bottling (far in the future, so it is the
// newest one with stock) and a product with a legal name.
productId = (
await prisma.product.create({
data: { name: `Readiness-Glas ${area}`, legalName: 'Blütenhonig' },
})
).id;
extractionId = (
await prisma.extraction.create({ data: { date: new Date(), userId } })
).id;
honeyBatchId = (
await prisma.honeyBatch.create({
data: {
batchNumber: `RA-${area}`,
extractionId,
amount: 25,
initialAmount: 25,
},
})
).id;
bottlingId = (
await prisma.bottlingBatch.create({
data: {
honeyBatchId,
productId,
date: new Date('2099-06-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 2,
totalAmount: 1,
bestBeforeDate: new Date('2101-06-01T00:00:00.000Z'),
lotNumber: `RA-L-${area}`,
userId,
},
})
).id;
templateId = (
await prisma.labelTemplate.create({
data: {
name: `Readiness-Vorlage ${area}`,
elements: [
textElement('legal', 1, 'legalName'),
textElement('net', 10, 'jarSize', 16),
textElement('mhd', 20, 'bestBeforeLine'),
textElement('lot', 29, 'lotLine'),
textElement('origin', 38, 'originCountry'),
textElement('firm', 47, 'foodBusinessName'),
textElement('address', 56, 'foodBusinessAddress'),
],
widthMm: 90,
heightMm: 70,
},
})
).id;
await setSite({
holderName: 'Imker E2E',
holderAddress: 'Weg 1, 12345 Ort',
foodBusinessName: 'Imkerei E2E',
foodBusinessAddress: 'Weg 2, 12345 Ort',
});
await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
});
afterAll(async () => {
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId: { in: created.lots } },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Treatment',
aggregateId: { in: created.treatments },
},
});
await prisma.treatment.deleteMany({
where: { id: { in: created.treatments } },
});
await prisma.veterinaryMedicineLot.deleteMany({
where: { id: { in: created.lots } },
});
await prisma.veterinaryMedicine.deleteMany({
where: { id: medicineId },
});
await prisma.harvestColony.deleteMany({
where: { harvestId: { in: created.harvests } },
});
await prisma.harvest.deleteMany({
where: { id: { in: created.harvests } },
});
await prisma.bottlingBatch.deleteMany({ where: { id: bottlingId } });
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.labelTemplate.deleteMany({ where: { id: templateId } });
await prisma.product.deleteMany({ where: { id: productId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
});
it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten rules', async () => {
await setVetOffice('Amt E2E');
const body = await report();
const keys = [
'treatment-log-gaps',
'treatment-location-uncertain',
'lot-gaps',
'lot-prescription',
'lot-expired-stock',
'treatment-expired-lot',
'label-template-gaps',
'label-template-form',
'medicine-withdrawal-missing',
];
const ownIds = [
treatmentOk,
treatmentSmall,
lotOk,
lotSmall,
medicineId,
templateId,
harvestAfter,
];
for (const key of ['holder-data', 'food-business-data', 'vet-office']) {
expect(itemOf(body, key).state).toBe('DONE');
}
for (const key of keys) {
const open = openSubjectIds(body, key);
for (const id of ownIds) {
expect({ key, id, open: open.includes(id) }).toEqual({
key,
id,
open: false,
});
}
}
});
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', async () => {
const year = Number(today.slice(0, 4));
const log = await buildTreatmentLog(prisma, year, apiaryId);
const rows = log.sheets
.flatMap((sheet) => sheet.rows)
.filter((row) => row.lotId === lotOk);
expect(rows).toHaveLength(1);
expect(treatmentLogGaps(rows[0])).toEqual([]);
const open = openSubjectIds(await report(), 'treatment-log-gaps');
expect(open).not.toContain(treatmentOk);
expect(open).not.toContain(lotOk);
});
it('the gap names of an incomplete treatment equal the gaps the Bestandsbuch row reports', async () => {
// Dated in the first year of the lookback: the item lists at most 50
// subjects and the shared database holds other treatments.
const year = Number(today.slice(0, 4)) - 5;
const id = await postTreatment({
date: `${year}-01-01`,
product: `Luecken-Mittel ${area}`,
withdrawalPeriodDays: 0,
});
const item = itemOf(await report(), 'treatment-log-gaps', String(year));
const subject = item.subjects.find((entry) => entry.id === id);
const log = await buildTreatmentLog(prisma, year, apiaryId);
const row = log.sheets
.flatMap((sheet) => sheet.rows)
.find((entry) => entry.product === `Luecken-Mittel ${area}`)!;
const names = {
SUPPLIER: 'Lieferant',
PROOF: 'Beleg',
AMOUNT: 'Menge',
WITHDRAWAL: 'Wartezeit',
VET: 'Tierarzt',
};
expect(item.state).toBe('OPEN');
expect(subject).toMatchObject({ type: 'TREATMENT', parentId: colonyId });
expect(subject!.missing).toEqual(
treatmentLogGaps(row).map((code) => names[code]),
);
expect(JSON.stringify(item)).not.toContain('Luecken-Mittel');
});
it('a negative rest after a treatment from a too small lot opens lot-negative', async () => {
const body = await report();
expect(itemOf(body, 'lot-negative').state).toBe('OPEN');
expect(openSubjectIds(body, 'lot-negative')).toContain(lotSmall);
expect(openSubjectIds(body, 'lot-negative')).not.toContain(lotOk);
});
it('lot-expired-stock is acknowledgeable per lot, a booking that changes the rest reopens it', async () => {
const before = itemOf(await report(), 'lot-expired-stock', lotOld);
expect(before).toMatchObject({ state: 'OPEN', acknowledgeable: true });
expect(openSubjectIds(await report(), 'treatment-expired-lot')).toContain(
treatmentOld,
);
const response = await acknowledge({
itemKey: 'lot-expired-stock',
subjectId: lotOld,
fingerprint: before.fingerprint,
note: 'Wird bei der nächsten Abgabe entsorgt.',
});
expect(response.status).toBe(201);
acknowledgementIds.push(
(response.body as ReadinessItem).acknowledgement!.id,
);
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
'ACKNOWLEDGED',
);
const disposal = await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lotOld}/movements`)
.set(as('beekeeper'))
.send({ type: 'DISPOSAL', quantity: 1, reason: 'Teil entsorgt' });
expect(disposal.status).toBe(201);
expect(itemOf(await report(), 'lot-expired-stock', lotOld).state).toBe(
'OPEN',
);
});
it('hides the lot subjects without veterinary-medicines:read but keeps the count', async () => {
const masked = itemOf(await report('complianceonly'), 'lot-negative');
const full = itemOf(await report('beekeeper'), 'lot-negative');
expect(masked.state).toBe('OPEN');
expect(masked.subjects).toEqual([]);
expect(masked.more).toBeGreaterThanOrEqual(1);
expect(JSON.stringify(masked)).not.toContain(medicineName);
expect(full.subjects.map((subject) => subject.id)).toContain(lotSmall);
});
it('opens harvest-withdrawal only for the harvest inside the window and acknowledges it', async () => {
const body = await report();
const open = openSubjectIds(body, 'harvest-withdrawal');
expect(open).toContain(harvestInside);
expect(open).not.toContain(harvestAfter);
const item = itemOf(body, 'harvest-withdrawal', harvestInside);
expect(JSON.stringify(item)).not.toContain('Wartezeit-Mittel');
const response = await acknowledge({
itemKey: 'harvest-withdrawal',
subjectId: harvestInside,
fingerprint: item.fingerprint,
note: 'Honig bleibt bis zum Ende der Wartezeit zurück.',
});
expect(response.status).toBe(201);
acknowledgementIds.push(
(response.body as ReadinessItem).acknowledgement!.id,
);
});
it('the label rules judge a template against the newest bottling with stock', async () => {
expect(
openSubjectIds(await report(), 'label-template-gaps'),
).not.toContain(templateId);
await prisma.labelTemplate.update({
where: { id: templateId },
data: { elements: [textElement('legal', 1, 'legalName')] },
});
expect(openSubjectIds(await report(), 'label-template-gaps')).toContain(
templateId,
);
});
const fetchPdf = (user: string) =>
request(server())
.get(`${READINESS}/report.pdf`)
.set(as(user))
.buffer(true)
.parse((res, callback) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
});
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', async () => {
const response = await fetchPdf('beekeeper');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toContain('application/pdf');
expect(response.headers['cache-control']).toBe('no-store');
expect(response.headers['content-disposition']).toContain(
'pruefbericht.pdf',
);
const text = pdfText(response.body as Buffer);
expect(text).toContain('Prüfbericht');
expect(text).not.toContain(medicineName);
expect((await fetchPdf('apiclient')).status).toBe(403);
});
it('the report PDF of a user with compliance:read but without veterinary-medicines:read names no medicine', async () => {
const response = await fetchPdf('complianceonly');
expect(response.status).toBe(200);
const text = pdfText(response.body as Buffer);
expect(text).toContain('Prüfbericht');
for (const name of [
medicineName,
`Wartezeit-Mittel ${area}`,
`Luecken-Mittel ${area}`,
]) {
expect(text).not.toContain(name);
}
});
});
});