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]>
302 lines
9.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|