Files
ApiCore/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts
T
Nick SzaunigandClaude Sonnet 5 8b8616ce03 fix: Etiketten werden beim Drucken abgeschnitten, wenn Spalten/Zeilen zu gross gewaehlt sind
Eine explizit im Etiketten-Designer eingetragene Spalten-/Zeilenzahl
wurde beim PDF-Rendern ungeprueft uebernommen. Passt sie nicht auf ein
A4-Blatt (z.B. 90mm breite Etiketten x 4 Spalten bei 5mm Rand), ragen
die hinteren Spalten/Zeilen ueber den Blattrand hinaus - der Drucker
schneidet sie dann einfach ab, statt sie zu drucken.

- Backend (label-templates.service.ts): Spalten/Zeilen werden jetzt auf
  das tatsaechlich auf A4 passende Maximum begrenzt, bevor gerendert
  wird - betrifft auch bereits gespeicherte Vorlagen mit zu grossen
  Werten.
- Frontend (label-designer.tsx): zeigt die maximal passende Spalten-/
  Zeilenzahl direkt im Seiten-Layout-Panel an und warnt inline, wenn
  der eingetragene Wert das Blatt sprengen wuerde.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-02 14:53:15 +02:00

302 lines
9.1 KiB
TypeScript

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);
});
});
});