- 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]>
296 lines
9.3 KiB
TypeScript
296 lines
9.3 KiB
TypeScript
import { Prisma } from '../../generated/prisma';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
|
|
|
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
|
|
|
describe('ApiaryRestrictionsService', () => {
|
|
let service: ApiaryRestrictionsService;
|
|
let tx: {
|
|
$queryRaw: jest.Mock;
|
|
apiary: { findMany: jest.Mock };
|
|
apiaryRestriction: {
|
|
create: jest.Mock;
|
|
update: jest.Mock;
|
|
findUnique: jest.Mock;
|
|
};
|
|
};
|
|
let prisma: {
|
|
apiary: { findUnique: jest.Mock };
|
|
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
|
|
$transaction: jest.Mock;
|
|
};
|
|
let auditLog: { record: jest.Mock };
|
|
|
|
beforeEach(() => {
|
|
tx = {
|
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
|
apiary: { findMany: jest.fn() },
|
|
apiaryRestriction: {
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
},
|
|
};
|
|
prisma = {
|
|
apiary: { findUnique: jest.fn() },
|
|
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
|
|
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
|
callback(tx),
|
|
),
|
|
};
|
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
|
service = new ApiaryRestrictionsService(
|
|
prisma as unknown as PrismaService,
|
|
auditLog as unknown as AuditLogService,
|
|
);
|
|
});
|
|
|
|
function lockStatements(): string[] {
|
|
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
|
|
([query]) => query.sql,
|
|
);
|
|
}
|
|
|
|
describe('create', () => {
|
|
const dto = {
|
|
apiaryIds: ['a2', 'a1'],
|
|
disease: 'Faulbrut',
|
|
startsOn: '2026-10-01',
|
|
};
|
|
|
|
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
|
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
|
tx.apiaryRestriction.create.mockImplementation(
|
|
({ data }: { data: { apiaryId: string } }) =>
|
|
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
|
|
);
|
|
|
|
const created = await service.create(dto, 'user-1');
|
|
|
|
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(lockStatements()).toEqual([
|
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
|
]);
|
|
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
|
|
[{ data: Record<string, unknown> }],
|
|
];
|
|
expect(firstRow.data).toMatchObject({
|
|
apiaryId: 'a1',
|
|
disease: 'Faulbrut',
|
|
startsOn: day('2026-10-01'),
|
|
createdByUserId: 'user-1',
|
|
});
|
|
expect(auditLog.record).toHaveBeenCalledTimes(2);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
entityType: 'ApiaryRestriction',
|
|
action: 'CREATE',
|
|
userId: 'user-1',
|
|
}),
|
|
tx,
|
|
);
|
|
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
|
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
|
|
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
|
|
|
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
|
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
|
|
expect(auditLog.record).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
|
|
await expect(
|
|
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects no apiary and more than 50 apiaries with 400', async () => {
|
|
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
|
|
|
|
await expect(
|
|
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
await expect(
|
|
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
const stored = {
|
|
id: 'r1',
|
|
apiaryId: 'a1',
|
|
disease: 'Faulbrut',
|
|
startsOn: day('2026-10-01'),
|
|
endsOn: null as Date | null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
|
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
|
tx.apiaryRestriction.update.mockImplementation(
|
|
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
|
|
);
|
|
});
|
|
|
|
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
|
|
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
|
|
|
|
expect(lockStatements()).toEqual([
|
|
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
|
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
|
|
]);
|
|
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
|
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
|
|
const result = await service.update(
|
|
'r1',
|
|
{ endsOn: '2026-10-20' },
|
|
'user-1',
|
|
);
|
|
|
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
|
where: { id: 'r1' },
|
|
data: { endsOn: day('2026-10-20') },
|
|
});
|
|
expect(result.endsOn).toEqual(day('2026-10-20'));
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
entityType: 'ApiaryRestriction',
|
|
entityId: 'r1',
|
|
action: 'UPDATE',
|
|
beforeData: stored,
|
|
}),
|
|
tx,
|
|
);
|
|
});
|
|
|
|
it('null clears endsOn', async () => {
|
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
|
...stored,
|
|
endsOn: day('2026-10-20'),
|
|
});
|
|
|
|
await service.update('r1', { endsOn: null }, 'user-1');
|
|
|
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
|
where: { id: 'r1' },
|
|
data: { endsOn: null },
|
|
});
|
|
});
|
|
|
|
it('checks endsOn >= startsOn on the merged state (400)', async () => {
|
|
await expect(
|
|
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
await expect(
|
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
|
).resolves.toBeDefined();
|
|
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
|
...stored,
|
|
endsOn: day('2026-10-20'),
|
|
});
|
|
await expect(
|
|
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('answers 404 for an unknown restriction without locking', async () => {
|
|
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
service.update('missing', { notes: 'x' }, 'user-1'),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('reads', () => {
|
|
it('findActive lists only restrictions active today, with the apiary name', async () => {
|
|
prisma.apiaryRestriction.findMany.mockResolvedValue([
|
|
{
|
|
id: 'r1',
|
|
apiaryId: 'a1',
|
|
apiary: { name: 'Nord' },
|
|
disease: 'Faulbrut',
|
|
startsOn: day('2000-01-01'),
|
|
endsOn: null,
|
|
},
|
|
{
|
|
id: 'r2',
|
|
apiaryId: 'a2',
|
|
apiary: { name: 'Sued' },
|
|
disease: 'Faulbrut',
|
|
startsOn: day('2000-01-01'),
|
|
endsOn: day('2000-01-02'),
|
|
},
|
|
]);
|
|
|
|
await expect(service.findActive()).resolves.toEqual([
|
|
{
|
|
id: 'r1',
|
|
apiaryId: 'a1',
|
|
apiaryName: 'Nord',
|
|
disease: 'Faulbrut',
|
|
startsOn: '2000-01-01',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
|
|
const row = {
|
|
id: 'r1',
|
|
apiaryId: 'a1',
|
|
disease: 'Faulbrut',
|
|
authority: 'Veterinaeramt',
|
|
reference: 'AZ 12/3',
|
|
notes: 'intern',
|
|
createdByUserId: 'u1',
|
|
startsOn: day('2026-10-01'),
|
|
endsOn: null,
|
|
};
|
|
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
|
|
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
|
|
const expected = [
|
|
{
|
|
id: 'r1',
|
|
apiaryId: 'a1',
|
|
disease: 'Faulbrut',
|
|
startsOn: '2026-10-01',
|
|
endsOn: null,
|
|
},
|
|
];
|
|
|
|
await expect(service.list(undefined, false)).resolves.toEqual(expected);
|
|
await expect(service.findForApiary('a1', false)).resolves.toEqual(
|
|
expected,
|
|
);
|
|
await expect(service.list(undefined, true)).resolves.toEqual([row]);
|
|
});
|
|
|
|
it('findForApiary answers 404 for an unknown apiary', async () => {
|
|
prisma.apiary.findUnique.mockResolvedValue(null);
|
|
|
|
await expect(service.findForApiary('missing')).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
});
|
|
});
|
|
});
|