Files
ApiCore/ApiCore-Backend/test/label-compliance.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 edc193f128
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s
feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

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

537 lines
16 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface GapBody {
code: string;
}
interface ConflictBody {
code: string;
message: string;
gaps: GapBody[];
}
interface CheckBody {
printable: boolean;
sample: boolean;
gaps: GapBody[];
warnings: GapBody[];
}
const SIZE = 80;
function textElement(
id: string,
y: number,
placeholderKey: string,
fontSize = 9,
) {
return {
id,
type: 'text',
x: 2,
y,
width: SIZE,
height: 8,
text: '',
placeholderKey,
fontSize,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
};
}
// Every mandatory particular, the net quantity big enough for 500 g.
const COMPLETE_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'),
];
describe('Label compliance (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = Date.now();
const password = 'SuperSecret123!';
const printerEmail = `label-compliance-printer-${suffix}@example.com`;
const readerEmail = `label-compliance-reader-${suffix}@example.com`;
let printerId: string;
let readerId: string;
let readerRoleId: string;
let labelOnlyId: string;
let labelOnlyRoleId: string;
let labelOnlyToken: string;
const labelOnlyEmail = `label-compliance-labelonly-${suffix}@example.com`;
let printerToken: string;
let readerToken: string;
let productId: string;
let templateId: string;
let honeyBatchId: string;
let extractionId: string;
let bottlingId: string;
let settingsBefore: {
foodBusinessName: string | null;
foodBusinessAddress: string | null;
originCountry: string;
} | null = null;
async function setMasterData(complete: boolean): Promise<void> {
// Other suites clear this table; the check refuses without the row.
await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
const data = {
foodBusinessName: complete ? 'Imkerei Muster' : null,
foodBusinessAddress: complete ? 'Bühlweg 10, 86450 Altenmünster' : null,
originCountry: 'Deutschland',
};
await prisma.siteSettings.upsert({
where: { id: 'singleton' },
update: data,
create: { id: 'singleton', ...data },
});
await prisma.product.update({
where: { id: productId },
data: { legalName: complete ? 'Blütenhonig' : null },
});
}
const printUrl = (query = '') =>
`/api/v1/label-templates/${templateId}/print/${bottlingId}${query}`;
const printAuditCount = () =>
prisma.auditLog.count({
where: {
entityType: 'BottlingBatch',
entityId: bottlingId,
action: 'PRINT',
},
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: printerEmail,
passwordHash,
name: 'Printer',
role: { connect: { key: 'ADMIN' } },
},
});
printerId = beekeeper.id;
// The test database grants the label permissions to ADMIN only, so the
// "can look but not print" user gets a role of its own.
const [readPermission, bottlingRead] = await Promise.all([
prisma.permission.findUniqueOrThrow({
where: { key: 'label-templates:read' },
}),
prisma.permission.findUniqueOrThrow({ where: { key: 'bottling:read' } }),
]);
const readerRole = await prisma.role.create({
data: {
key: `label-compliance-reader-${suffix}`,
name: 'Label and bottling reader',
permissions: {
create: [
{ permissionId: readPermission.id },
{ permissionId: bottlingRead.id },
],
},
},
});
readerRoleId = readerRole.id;
const reader = await prisma.user.create({
data: {
email: readerEmail,
passwordHash,
name: 'Reader',
roleId: readerRole.id,
},
});
readerId = reader.id;
// Sees label templates, but not bottlings.
const labelOnlyRole = await prisma.role.create({
data: {
key: `label-compliance-labelonly-${suffix}`,
name: 'Label reader',
permissions: { create: { permissionId: readPermission.id } },
},
});
labelOnlyRoleId = labelOnlyRole.id;
const labelOnly = await prisma.user.create({
data: {
email: labelOnlyEmail,
passwordHash,
name: 'Label only',
roleId: labelOnlyRole.id,
},
});
labelOnlyId = labelOnly.id;
labelOnlyToken = (await login(app, labelOnlyEmail, password)).accessToken;
printerToken = (await login(app, printerEmail, password)).accessToken;
readerToken = (await login(app, readerEmail, password)).accessToken;
settingsBefore = await prisma.siteSettings.findUnique({
where: { id: 'singleton' },
select: {
foodBusinessName: true,
foodBusinessAddress: true,
originCountry: true,
},
});
const product = await prisma.product.create({
data: { name: `Label-Compliance-Glas ${suffix}` },
});
productId = product.id;
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: printerId },
});
extractionId = extraction.id;
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `LC-${suffix}`,
extractionId,
amount: 25,
initialAmount: 25,
},
});
honeyBatchId = honeyBatch.id;
const bottling = await prisma.bottlingBatch.create({
data: {
honeyBatchId,
productId,
date: new Date('2026-06-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 2,
totalAmount: 1,
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
lotNumber: `L-${suffix}`,
userId: printerId,
},
});
bottlingId = bottling.id;
const template = await prisma.labelTemplate.create({
data: {
name: `Label-Compliance ${suffix}`,
elements: COMPLETE_ELEMENTS,
widthMm: 90,
heightMm: 70,
},
});
templateId = template.id;
});
afterAll(async () => {
await prisma.auditLog.deleteMany({
where: { entityType: 'BottlingBatch', entityId: bottlingId },
});
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 } });
if (settingsBefore) {
await prisma.siteSettings.update({
where: { id: 'singleton' },
data: settingsBefore,
});
}
await prisma.refreshToken.deleteMany({
where: { userId: { in: [printerId, readerId, labelOnlyId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [printerId, readerId, labelOnlyId] } },
});
await prisma.role.deleteMany({
where: { id: { in: [readerRoleId, labelOnlyRoleId] } },
});
await app.close();
});
it('409 with the gap list, then 200 and an audit PRINT after the master data is complete', async () => {
await setMasterData(false);
const refused = await request(app.getHttpServer())
.get(printUrl())
.set('Authorization', `Bearer ${printerToken}`)
.expect(409);
const body = refused.body as ConflictBody;
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
'FOOD_BUSINESS',
'LEGAL_NAME',
]);
expect(await printAuditCount()).toBe(0);
await setMasterData(true);
const printed = await request(app.getHttpServer())
.get(printUrl())
.set('Authorization', `Bearer ${printerToken}`)
.expect(200);
expect(printed.headers['content-type']).toContain('application/pdf');
expect((printed.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
'%PDF',
);
const entries = await prisma.auditLog.findMany({
where: {
entityType: 'BottlingBatch',
entityId: bottlingId,
action: 'PRINT',
},
});
expect(entries).toHaveLength(1);
expect(entries[0].userId).toBe(printerId);
expect(entries[0].afterData).toEqual({
templateId,
copies: 2,
proof: false,
gapCodes: [],
warningCodes: [],
});
});
it('GET print with proof=true answers 200 although a particular is missing', async () => {
await setMasterData(false);
const before = await printAuditCount();
const response = await request(app.getHttpServer())
.get(printUrl('?proof=true'))
.set('Authorization', `Bearer ${printerToken}`)
.expect(200);
expect((response.body as Buffer).subarray(0, 4).toString('latin1')).toBe(
'%PDF',
);
expect(await printAuditCount()).toBe(before + 1);
const latest = await prisma.auditLog.findFirst({
where: { entityType: 'BottlingBatch', entityId: bottlingId },
orderBy: { timestamp: 'desc' },
});
expect(latest?.afterData).toMatchObject({
proof: true,
gapCodes: expect.arrayContaining(['LEGAL_NAME']) as unknown,
});
});
it('rejects a malformed proof flag with 400', async () => {
await request(app.getHttpServer())
.get(printUrl('?proof=maybe'))
.set('Authorization', `Bearer ${printerToken}`)
.expect(400);
});
it('GET check answers the same gaps the print refuses with', async () => {
await setMasterData(false);
const response = await request(app.getHttpServer())
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
.set('Authorization', `Bearer ${readerToken}`)
.expect(200);
const body = response.body as CheckBody;
expect(body.printable).toBe(false);
expect(body.sample).toBe(false);
expect(body.gaps.map((gap) => gap.code).sort()).toEqual([
'FOOD_BUSINESS',
'LEGAL_NAME',
]);
expect(response.headers['cache-control']).toContain('no-store');
});
it('POST check without a bottling uses the sample values', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${readerToken}`)
.send({
elements: [textElement('legal', 1, 'legalName')],
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
})
.expect(200);
const body = response.body as CheckBody;
expect(body.sample).toBe(true);
expect(body.gaps.map((gap) => gap.code)).not.toContain('LEGAL_NAME');
});
it('POST check rejects an image element with 400', async () => {
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${readerToken}`)
.send({
elements: [
{
id: 'img',
type: 'image',
x: 0,
y: 0,
width: 10,
height: 10,
fontSize: 9,
src: 'data:image/png;base64,AAAA',
},
],
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
})
.expect(400);
});
it('POST check with 101 elements answers 400', async () => {
const elements = Array.from({ length: 101 }, (_, index) =>
textElement(`el-${index}`, 1, 'legalName'),
);
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${readerToken}`)
.send({ elements, widthMm: 90, heightMm: 70, minXHeightMm: 1.2 })
.expect(400);
});
it('print needs label-templates:print, check needs label-templates:read', async () => {
await request(app.getHttpServer())
.get(printUrl('?proof=true'))
.set('Authorization', `Bearer ${readerToken}`)
.expect(403);
await request(app.getHttpServer())
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
.expect(401);
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.expect(401);
});
it('?proof=false with a gap answers 409, ?proof=0 answers 400', async () => {
await setMasterData(false);
await request(app.getHttpServer())
.get(printUrl('?proof=false'))
.set('Authorization', `Bearer ${printerToken}`)
.expect(409);
await request(app.getHttpServer())
.get(printUrl('?proof=0'))
.set('Authorization', `Bearer ${printerToken}`)
.expect(400);
});
it.each(['0', '-1', '1001', 'x'])(
'startPosition=%s answers 400',
async (value) => {
await setMasterData(true);
await request(app.getHttpServer())
.get(printUrl(`?startPosition=${value}`))
.set('Authorization', `Bearer ${printerToken}`)
.expect(400);
},
);
it('a label-only reader gets 403 on check and print, and on a draft check with a bottling', async () => {
await request(app.getHttpServer())
.get(`/api/v1/label-templates/${templateId}/check/${bottlingId}`)
.set('Authorization', `Bearer ${labelOnlyToken}`)
.expect(403);
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${labelOnlyToken}`)
.send({
elements: [textElement('legal', 1, 'legalName')],
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
bottlingBatchId: bottlingId,
})
.expect(403);
});
it('a label-only reader may check a draft with the sample values', async () => {
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${labelOnlyToken}`)
.send({
elements: [textElement('legal', 1, 'legalName')],
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
})
.expect(200);
});
it('POST check rejects duplicate element ids and out-of-range sizes with 400', async () => {
const draft = (elements: unknown[]) => ({
elements,
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
});
const element = textElement('same', 1, 'legalName');
for (const body of [
draft([element, element]),
draft([{ ...element, width: 5000 }]),
draft([{ ...element, x: -5000 }]),
{ ...draft([element]), widthMm: 5000 },
]) {
await request(app.getHttpServer())
.post('/api/v1/label-templates/check')
.set('Authorization', `Bearer ${readerToken}`)
.send(body)
.expect(400);
}
});
it('print and check answer 409 when the compliance settings row is missing', async () => {
await setMasterData(true);
const saved = await prisma.complianceSettings.findUniqueOrThrow({
where: { id: 'singleton' },
});
const restore = {
...saved,
netQuantityDigitRules: saved.netQuantityDigitRules as object,
};
await prisma.complianceSettings.delete({ where: { id: 'singleton' } });
try {
const response = await request(app.getHttpServer())
.get(printUrl())
.set('Authorization', `Bearer ${printerToken}`)
.expect(409);
expect((response.body as { code: string }).code).toBe(
'COMPLIANCE_SETTINGS_INVALID',
);
} finally {
await prisma.complianceSettings.create({ data: restore });
}
});
it('the response of a print is no-store', async () => {
await setMasterData(true);
const response = await request(app.getHttpServer())
.get(printUrl())
.set('Authorization', `Bearer ${printerToken}`)
.expect(200);
expect(response.headers['cache-control']).toContain('no-store');
});
});