import { NotFoundException } from '@nestjs/common'; import { LabelTemplatesService, buildPlaceholderValues, } from './label-templates.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { LabelElement } from './label-element.types'; describe('LabelTemplatesService', () => { let service: LabelTemplatesService; let tx: { labelTemplate: { updateMany: jest.Mock; create: jest.Mock; update: jest.Mock; }; }; let prisma: { labelTemplate: { findMany: jest.Mock; findUnique: jest.Mock; update: jest.Mock; }; bottlingBatch: { findUnique: jest.Mock }; $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; const elements: LabelElement[] = [ { id: 'el-1', type: 'text', x: 2, y: 2, width: 60, height: 10, text: '', placeholderKey: 'batchNumber', fontSize: 9, fontFamily: 'sans', bold: false, italic: false, color: '#000000', align: 'left', }, { id: 'el-2', type: 'text', x: 2, y: 14, width: 60, height: 10, text: 'MHD:', fontSize: 9, fontFamily: 'sans', bold: false, italic: false, color: '#000000', align: 'left', }, ]; const template = { id: 'template-1', name: 'Standard', elements, widthMm: 90, heightMm: 35, columns: null, rows: null, marginTopMm: 5, marginLeftMm: 5, gapXMm: 2, gapYMm: 2, isDefault: false, active: true, }; beforeEach(() => { tx = { labelTemplate: { updateMany: jest.fn().mockResolvedValue({}), create: jest.fn(), update: jest.fn(), }, }; prisma = { labelTemplate: { findMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, bottlingBatch: { findUnique: jest.fn() }, $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; service = new LabelTemplatesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, ); }); it('findById throws NotFoundException when missing', async () => { prisma.labelTemplate.findUnique.mockResolvedValue(null); await expect(service.findById('missing')).rejects.toBeInstanceOf( NotFoundException, ); }); describe('create', () => { it('clears other defaults first when isDefault is set', async () => { tx.labelTemplate.create.mockResolvedValue({ ...template, isDefault: true, }); await service.create( { name: 'Standard', elements: [], isDefault: true }, 'user-1', ); expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({ where: {}, data: { isDefault: false }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'CREATE' }), ); }); it('does not touch other defaults when isDefault is not set', async () => { tx.labelTemplate.create.mockResolvedValue(template); await service.create({ name: 'Standard', elements: [] }, 'user-1'); expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled(); }); }); describe('deactivate', () => { it('sets active and isDefault to false, records DEACTIVATE', async () => { prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.labelTemplate.update.mockResolvedValue({ ...template, active: false, isDefault: false, }); const result = await service.deactivate('template-1', 'user-1'); expect(result.active).toBe(false); expect(prisma.labelTemplate.update).toHaveBeenCalledWith({ where: { id: 'template-1' }, data: { active: false, isDefault: false }, }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ action: 'DEACTIVATE' }), ); }); }); describe('renderLabelsPdf', () => { it('resolves placeholder text and tiles one label per jar', async () => { prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 2, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); expect(pdf).toBeInstanceOf(Buffer); expect(pdf.length).toBeGreaterThan(0); // %PDF header confirms a real PDF document was produced. expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); }); it('throws NotFoundException when the bottling batch does not exist', async () => { prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.bottlingBatch.findUnique.mockResolvedValue(null); await expect( service.renderLabelsPdf('template-1', 'missing'), ).rejects.toBeInstanceOf(NotFoundException); }); it('embeds a QR code pointing at the Herkunft page when the template has a qrCode element', async () => { const qrElement: LabelElement = { id: 'el-qr', type: 'qrCode', x: 60, y: 2, size: 20, }; prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, elements: [...elements, qrElement], }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 1, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); // A PDF with an embedded raster image contains an XObject /Image entry. expect(pdf.toString('latin1')).toContain('/Image'); }); it('embeds an image element whose bytes match the declared PNG mime type', async () => { // Minimal valid 1x1 transparent PNG, widely used as a test fixture. const onePixelPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; const imageElement: LabelElement = { id: 'el-img', type: 'image', x: 5, y: 5, width: 10, height: 10, src: `data:image/png;base64,${onePixelPng}`, }; prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, elements: [imageElement], }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 1, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); // pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet // regardless of whether anything is actually embedded, so a plain // "/Image" substring check would pass either way - /Subtype /Image is // the actual embedded-XObject marker. expect(pdf.toString('latin1')).toMatch(/\/Subtype\s*\/Image\b/); }); it('silently skips an image element whose bytes do not match the declared mime type', async () => { const mislabeled = Buffer.from('not-a-png').toString('base64'); const imageElement: LabelElement = { id: 'el-img', type: 'image', x: 5, y: 5, width: 10, height: 10, src: `data:image/png;base64,${mislabeled}`, }; prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, elements: [imageElement], }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 1, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/); }); function countPages(pdf: Buffer): number { const matches = pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g); return matches ? matches.length : 0; } it('uses an explicit columns/rows override instead of auto-fitting', async () => { prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, columns: 1, rows: 1, }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 3, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); expect(countPages(pdf)).toBe(3); }); it('clamps an explicit columns/rows override that would run labels off the A4 page', async () => { // widthMm:90, marginLeftMm:5, gapXMm:2 only fit 2 columns on A4 - an // explicit 99 would place columns 3+ past the page's right edge, // where a printer just clips them instead of printing anything. prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, columns: 99, rows: 1, }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 3, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); // Clamped to the real max of 2 columns x 1 row = 2 per page, so 3 // copies still need 2 pages - same as the explicit columns:2 test. expect(countPages(pdf)).toBe(2); }); it('rotates labels 90 degrees when that fits more per sheet than the upright orientation', async () => { // widthMm:90, heightMm:35, marginTopMm/Left:5, gapX/Y:2 (the default // fixture): upright fits 2 columns x 7 rows = 14 per page, but rotated // (35mm-wide x 90mm-tall slots) fits 5 columns x 3 rows = 15 per page. prisma.labelTemplate.findUnique.mockResolvedValue(template); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 15, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); // Only fits on a single page if the rotated 15-per-page layout was // actually used - upright would need a second page for the 15th copy. expect(countPages(pdf)).toBe(1); }); it('does not rotate when columns/rows are explicitly set, even if rotating would fit more', async () => { prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, columns: 2, rows: 7, }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 15, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); // An explicit grid describes a real pre-printed sheet - it must be // honoured as-is (14 per page), needing 2 pages for 15 copies, even // though the auto-fit rotation logic would otherwise fit all 15 on one. expect(countPages(pdf)).toBe(2); }); it('skips already-used slots on the first sheet via startPosition, but not later sheets', async () => { prisma.labelTemplate.findUnique.mockResolvedValue({ ...template, columns: 2, rows: 1, }); prisma.bottlingBatch.findUnique.mockResolvedValue({ id: 'batch-1', jarSize: 500, jarCount: 3, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }); const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2); // perPage=2, skip=1: label 1 fills the remaining slot on page 1, // labels 2-3 fill both slots of page 2. expect(countPages(pdf)).toBe(2); }); }); describe('buildPlaceholderValues', () => { const bottlingBatch = { jarSize: 500, lotNumber: 'L-001', date: new Date('2026-06-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, product: { name: 'Glas 500g' }, }; it('prints the jar size in grams, the unit honey is sold by', () => { expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g'); }); it('never labels the jar size as a volume', () => { expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch( /\b(ml|l|cl)\b/, ); }); it('leaves an unset honey type and product as empty text, not "null"', () => { const values = buildPlaceholderValues({ ...bottlingBatch, honeyBatch: { batchNumber: 'HB-001', honeyType: null }, product: null, }); expect(values.honeyType).toBe(''); expect(values.productName).toBe(''); }); }); });