- 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]>
537 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|