import { NotFoundException } from '@nestjs/common'; import { LabelTemplatesService } 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'); }); 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('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); }); }); });