import type { Prisma } from '../../generated/prisma'; import type { AnyDomainEventEnvelope, DomainEventType, } from '../domain-events/domain-event-types'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import { lockRowsForUpdate } from '../prisma/row-lock'; import { AutopilotEventHandlers } from './autopilot.handlers'; import type { AutopilotService } from './autopilot.service'; jest.mock('../prisma/row-lock'); const lockRows = jest.mocked(lockRowsForUpdate); const EXPECTED_TYPES: DomainEventType[] = [ 'InspectionRecorded', 'InspectionCorrected', 'VarroaMeasured', 'VarroaCorrected', 'TreatmentRecorded', 'TreatmentCorrected', 'FeedingRecorded', 'FeedingCorrected', 'HarvestRecorded', 'HarvestCorrected', 'ColonyStatusChanged', 'ColonySplit', 'QueenAssigned', 'TaskCompleted', 'TaskUpdated', 'WithdrawalEnded', 'QueenAged', 'InspectionOverdue', 'VarroaMeasurementDue', 'WinterCheckRecorded', ]; function event( type: DomainEventType, aggregateId: string, payload: object, ): AnyDomainEventEnvelope { return { id: 'evt-1', type, aggregateType: 'Colony', aggregateId, payload, actorUserId: null, correlationId: 'cor-1', occurredAt: new Date(), attempts: 0, } as AnyDomainEventEnvelope; } describe('AutopilotEventHandlers', () => { let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock }; let tx: { task: { findUnique: jest.Mock } }; let handlers: AutopilotEventHandlers; beforeEach(() => { lockRows.mockReset(); jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') }); autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}), syncApiaryIn: jest.fn().mockResolvedValue({}), }; tx = { task: { findUnique: jest.fn() } }; handlers = new AutopilotEventHandlers( autopilot as unknown as AutopilotService, ); }); afterEach(() => { jest.useRealTimers(); }); const run = (e: AnyDomainEventEnvelope) => handlers.colonySync( e as Parameters[0], tx as unknown as Prisma.TransactionClient, ); const synced = () => autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]); it('is registered as autopilot.colony-sync for the listed events', () => { const entries = domainEventHandlerMetadata( // eslint-disable-next-line @typescript-eslint/unbound-method AutopilotEventHandlers.prototype.colonySync, ); expect(entries).toHaveLength(1); expect(entries[0].handlerId).toBe('autopilot.colony-sync'); expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort()); }); it.each([ ['InspectionRecorded', { colonyId: 'c1' }], ['InspectionCorrected', { colonyId: 'c1' }], ['VarroaMeasured', { colonyId: 'c1' }], ['VarroaCorrected', { colonyId: 'c1' }], ['TreatmentRecorded', { colonyId: 'c1' }], ['TreatmentCorrected', { colonyId: 'c1' }], ['FeedingRecorded', { colonyId: 'c1' }], ['FeedingCorrected', { colonyId: 'c1' }], ['WinterCheckRecorded', { colonyId: 'c1' }], ['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }], ['QueenAged', { colonyId: 'c1', hatchYear: 2024 }], ] as [DomainEventType, object][])( '%s syncs the colony of its payload for the Berlin day', async (type, payload) => { await run(event(type, 'aggregate-1', payload)); expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1); expect(autopilot.syncColonyIn).toHaveBeenCalledWith( tx, 'c1', '2026-05-12', ); }, ); it.each([ 'ColonyStatusChanged', 'InspectionOverdue', 'VarroaMeasurementDue', ] as const)('%s syncs the aggregate colony', async (type) => { await run(event(type, 'c9', {})); expect(synced()).toEqual(['c9']); }); it('syncs every colony of a harvest, in id order', async () => { await run( event('HarvestRecorded', 'h1', { apiaryId: 'a', colonyIds: ['c3', 'c1', 'c2', 'c1'], }), ); expect(synced()).toEqual(['c1', 'c2', 'c3']); }); it('locks all colonies of an event in one statement before syncing any', async () => { await run( event('HarvestRecorded', 'h1', { apiaryId: 'a', colonyIds: ['c3', 'c1', 'c2'], }), ); expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']); expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan( autopilot.syncColonyIn.mock.invocationCallOrder[0], ); }); it('takes no extra lock for a single colony', async () => { await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })); expect(lockRows).not.toHaveBeenCalled(); }); it('syncs the new and the parent colony of a split', async () => { await run(event('ColonySplit', 'new', { parentColonyId: 'mother' })); expect(synced()).toEqual(['mother', 'new']); }); it('syncs the colony and the previous colony of a queen assignment', async () => { await run( event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }), ); await run( event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }), ); expect(synced()).toEqual(['c1', 'c2', 'c2']); }); it('syncs a colony task on completion and update, nothing for other tasks', async () => { await run( event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }), ); await run( event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }), ); tx.task.findUnique .mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' }) .mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' }) .mockResolvedValueOnce(null); await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null })); await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null })); await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null })); expect(synced()).toEqual(['c1', 'c4']); }); it('does nothing for a queen event without a colony', async () => { await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 })); expect(autopilot.syncColonyIn).not.toHaveBeenCalled(); }); it('lets errors propagate so the delivery is retried', async () => { autopilot.syncColonyIn.mockRejectedValue(new Error('db down')); await expect( run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })), ).rejects.toThrow('db down'); }); describe('apiarySync', () => { it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => { const entries = domainEventHandlerMetadata( // eslint-disable-next-line @typescript-eslint/unbound-method AutopilotEventHandlers.prototype.apiarySync, ); expect(entries).toHaveLength(1); expect(entries[0].handlerId).toBe('autopilot.apiary-sync'); expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']); }); // One event per apiary and day (dailyDedupeKey): a change later the same // day is caught by the daily autopilot step. it('syncs the apiary of the payload for the Berlin day', async () => { await handlers.apiarySync( event('SeasonPhaseChanged', 'api-1', { apiaryId: 'api-1', phase: 'KIRSCHE', segment: 'SCHWARMZEIT', previousPhase: 'SALWEIDE', previousSegment: 'FRUEHJAHR', source: 'REPORT', }) as Parameters[0], tx as unknown as Prisma.TransactionClient, ); expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1); expect(autopilot.syncApiaryIn).toHaveBeenCalledWith( tx, 'api-1', '2026-05-12', ); expect(autopilot.syncColonyIn).not.toHaveBeenCalled(); }); it('lets errors propagate so the delivery is retried', async () => { autopilot.syncApiaryIn.mockRejectedValue(new Error('db down')); await expect( handlers.apiarySync( event('SeasonPhaseChanged', 'api-1', { apiaryId: 'api-1', }) as Parameters[0], tx as unknown as Prisma.TransactionClient, ), ).rejects.toThrow('db down'); }); }); });