Files
ApiCore/ApiCore-Backend/src/compliance/compliance-settings.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 edc193f128
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s
feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:18:41 +02:00

237 lines
6.7 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { ComplianceSettingsService } from './compliance-settings.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const STORED = {
id: 'singleton',
veterinaryOfficeName: null,
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
updatedAt: new Date(0),
};
describe('ComplianceSettingsService', () => {
let service: ComplianceSettingsService;
let tx: {
complianceSettings: {
upsert: jest.Mock;
findUniqueOrThrow: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
complianceSettings: { findUnique: jest.Mock; upsert: jest.Mock };
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
tx = {
complianceSettings: {
upsert: jest.fn().mockResolvedValue(STORED),
findUniqueOrThrow: jest.fn().mockResolvedValue(STORED),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...STORED, ...data }),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue(STORED),
},
};
auditLog = { record: jest.fn() };
service = new ComplianceSettingsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('creates defaults on first read', async () => {
const settings = await service.get();
expect(prisma.complianceSettings.upsert).toHaveBeenCalledWith({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
expect(settings).toBe(STORED);
});
it('returns the stored row without creating one', async () => {
prisma.complianceSettings.findUnique.mockResolvedValue(STORED);
await service.get();
expect(prisma.complianceSettings.upsert).not.toHaveBeenCalled();
});
it('rejects 02-29 as reference day with 400', async () => {
await expect(
service.update({ tskReferenceDay: '02-29' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update({ tskReportDueDay: '13-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects digit rules that are not ascending or whose last rule has a limit', async () => {
const attempts = [
[
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 50, minMm: 3 },
{ maxGrams: null, minMm: 6 },
],
[{ maxGrams: 50, minMm: 2 }],
[
{ maxGrams: null, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[],
];
for (const rules of attempts) {
await expect(
service.update({ netQuantityDigitRules: rules }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
}
});
it('rejects a digit height outside 0.5 to 20 mm and more than 10 rules', async () => {
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 0.4 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 20.5 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
const eleven = Array.from({ length: 10 }, (_, index) => ({
maxGrams: (index + 1) * 10,
minMm: 2,
})).concat([{ maxGrams: null as never, minMm: 2 }]);
await expect(
service.update({ netQuantityDigitRules: eleven }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('treats a missing maxGrams key like null', async () => {
await service.update(
{
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ minMm: 6 } as never,
],
},
'u1',
);
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: { netQuantityDigitRules: unknown };
};
expect(call.data.netQuantityDigitRules).toEqual([
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
]);
await expect(
service.update(
{
netQuantityDigitRules: [{ minMm: 2 } as never, { minMm: 6 } as never],
},
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a colony type listed twice', async () => {
await expect(
service.update(
{ tskExcludedColonyTypes: ['NUCLEUS_COLONY', 'NUCLEUS_COLONY'] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('stores valid values under the row lock with an audit entry', async () => {
await service.update(
{
veterinaryOfficeName: ' Veterinäramt ',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
},
'u1',
);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'ComplianceSettings',
'singleton',
);
expect(tx.complianceSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: expect.objectContaining({
veterinaryOfficeName: 'Veterinäramt',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ComplianceSettings',
entityId: 'singleton',
action: 'UPDATE',
userId: 'u1',
}),
tx,
);
});
it('an omitted field keeps the stored value', async () => {
await service.update({ noticeOnApiaryChange: false }, 'u1');
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: Record<string, unknown>;
};
expect(call.data.noticeOnApiaryChange).toBe(false);
expect(call.data.tskReferenceDay).toBeUndefined();
expect(call.data.veterinaryOfficeName).toBeUndefined();
});
});