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]>
This commit is contained in:
1 parent
c18860c75c
commit
edc193f128
73 files changed
+9470
-171
No files matched your search
@@ -0,0 +1,536 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user