import { WinterChecksService } from './winter-checks.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; describe('WinterChecksService', () => { const livingColony = { id: 'c1', status: 'ACTIVE', healthStatus: null, apiaryId: 'a1', }; const lostCheck: CreateWinterCheckDto = { colonyId: 'c1', season: 'SPRING', date: '2027-03-20', outcome: 'LOST', lossCause: 'DEAD_OR_EMPTY', markColonyDeceased: true, }; let service: WinterChecksService; let tx: { $queryRaw: jest.Mock; winterCheck: { create: jest.Mock }; colony: { update: jest.Mock; findUnique: jest.Mock }; queenColonyHistory: { updateMany: jest.Mock }; auditLog: { create: jest.Mock }; }; let prisma: { colony: { findUnique: jest.Mock }; $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; let events: { recordAll: jest.Mock; record: jest.Mock }; beforeEach(() => { tx = { $queryRaw: jest.fn().mockResolvedValue([]), winterCheck: { create: jest .fn() .mockImplementation(({ data }: { data: object }) => Promise.resolve({ id: 'w1', ...data }), ), }, colony: { findUnique: jest.fn().mockResolvedValue(livingColony), update: jest .fn() .mockResolvedValue({ ...livingColony, status: 'DECEASED' }), }, queenColonyHistory: { updateMany: jest.fn() }, auditLog: { create: jest.fn() }, }; prisma = { colony: { findUnique: jest.fn().mockResolvedValue(livingColony) }, $transaction: jest .fn() .mockImplementation((work: (client: typeof tx) => Promise) => work(tx), ), }; auditLog = { record: jest.fn() }; events = { recordAll: jest.fn().mockResolvedValue(0), record: jest.fn().mockResolvedValue(undefined), }; service = new WinterChecksService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, events as unknown as DomainEventsService, ); }); it('records WinterCheckRecorded with ids only in the same transaction', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); expect(events.record).toHaveBeenCalledWith(tx, { type: 'WinterCheckRecorded', aggregateType: 'WinterCheck', aggregateId: 'w1', actorUserId: 'u1', payload: { colonyId: 'c1' }, }); }); it('audits a colony marked deceased through the audit service inside the transaction', async () => { await service.create(lostCheck, 'u1'); expect(auditLog.record).toHaveBeenCalledWith( { entityType: 'Colony', entityId: 'c1', action: 'UPDATE', userId: 'u1', beforeData: livingColony, afterData: { ...livingColony, status: 'DECEASED' }, }, tx, ); expect(tx.auditLog.create).not.toHaveBeenCalled(); }); it('writes no colony audit when the colony is not to be marked deceased', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); expect(tx.colony.update).not.toHaveBeenCalled(); const auditedTypes = ( auditLog.record.mock.calls as [{ entityType: string }][] ).map(([input]) => input.entityType); expect(auditedTypes).toEqual(['WinterCheck']); }); it('a winter loss with markColonyDeceased records ColonyEnded "DECEASED" in the same transaction', async () => { await service.create(lostCheck, 'u1'); expect(events.recordAll).toHaveBeenCalledWith(tx, [ expect.objectContaining({ type: 'ColonyStatusChanged', aggregateId: 'c1', actorUserId: 'u1', payload: { from: { status: 'ACTIVE', healthStatus: null }, to: { status: 'DECEASED', healthStatus: null }, }, }), expect.objectContaining({ type: 'ColonyEnded', aggregateId: 'c1', payload: { status: 'DECEASED', endedOn: '2027-03-20', releasedQueenId: null, via: 'WINTER_CHECK', }, }), ]); }); it('a winter loss with markColonyDeceased releases the queen with the check date', async () => { tx.colony.findUnique.mockResolvedValue({ ...livingColony, currentQueenId: 'q1', }); await service.create(lostCheck, 'u1'); const endDate = new Date('2027-03-20T00:00:00.000Z'); expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({ where: { queenId: 'q1', colonyId: 'c1', endDate: null }, data: { endDate }, }); expect(tx.colony.update).toHaveBeenCalledWith({ where: { id: 'c1' }, data: { currentQueenId: null }, }); expect(events.recordAll).toHaveBeenCalledWith(tx, [ expect.anything(), expect.objectContaining({ type: 'ColonyEnded', payload: expect.objectContaining({ releasedQueenId: 'q1', endedOn: '2027-03-20', }) as object, }), ]); }); it('releases no queen when the colony is not marked deceased', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); }); it('records no colony event when the colony is not to be marked deceased', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); expect(events.recordAll).not.toHaveBeenCalled(); }); it('audits the winter check inside the transaction', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'WinterCheck', action: 'CREATE' }), tx, ); }); it('locks the colony and uses the locked row as "before" for event and audit', async () => { tx.colony.findUnique.mockResolvedValue({ ...livingColony, healthStatus: 'CRITICAL', }); await service.create(lostCheck, 'u1'); expect(tx.$queryRaw).toHaveBeenCalledTimes(1); expect(events.recordAll).toHaveBeenCalledWith(tx, [ expect.objectContaining({ payload: { from: { status: 'ACTIVE', healthStatus: 'CRITICAL' }, to: { status: 'DECEASED', healthStatus: null }, }, }), expect.anything(), ]); }); it('does not mark a colony deceased that ended between read and lock', async () => { tx.colony.findUnique.mockResolvedValue({ ...livingColony, status: 'DISSOLVED', }); await service.create(lostCheck, 'u1'); expect(tx.colony.update).not.toHaveBeenCalled(); expect(events.recordAll).not.toHaveBeenCalled(); }); });