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; 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 { // 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'); }); });