Files
ApiCore/ApiCore-Backend/src/apiary-restrictions/apiary-restrictions.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

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