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