- 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]>
237 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|