renderLabelsPdf resolved the jarSize placeholder to "500 ml". Everywhere else in the app jarSize is a fill weight in grams - the bottling forms label it "Glasgröße (g)", the batch list and the public Herkunft page print "g", and BottlingService divides by 1000 to get kilograms. Only the printed label said millilitres, so every label produced so far declares the wrong net quantity for a food sold by mass. The placeholder mapping moved into an exported buildPlaceholderValues() so the wording can be asserted directly. It could not be before: the render output is a PDF buffer whose text a test cannot read, which is how the wrong unit shipped unnoticed. Three tests now cover it, including one that rejects any volume unit outright. Labels already printed with "500 ml" have to be reprinted - the code change does not correct paper. Co-Authored-By: Claude Opus 5 <[email protected]>
454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
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('');
|
|
});
|
|
});
|
|
});
|