import { Prisma } from '../../generated/prisma'; import type { AnyDomainEventEnvelope, DomainEventEnvelope, DomainEventType, } from '../domain-events/domain-event-types'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import type { StockService } from '../stock/stock.service'; import { AutomationEventHandlers } from './automation.handlers'; import type { AutomationService } from './automation.service'; type RecordModel = 'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'colony'; const RECORD_MODELS: readonly RecordModel[] = [ 'inspection', 'varroaMeasurement', 'treatment', 'feeding', 'colony', ]; function envelope( type: T, aggregateId: string, payload: DomainEventEnvelope['payload'], ): AnyDomainEventEnvelope { return { id: 'e1', type, aggregateType: 'Inspection', aggregateId, payload, actorUserId: 'u1', correlationId: 'corr-1', occurredAt: new Date('2026-10-13T08:00:00.000Z'), attempts: 0, } as unknown as AnyDomainEventEnvelope; } describe('AutomationEventHandlers', () => { let tx: Record; let automation: Record< | 'evaluateInspectionIn' | 'evaluateVarroaMeasurementIn' | 'evaluateTreatmentIn' | 'evaluateFeedingIn' | 'evaluateColonyHealthIn' | 'evaluateRawMaterialLowIn', jest.Mock >; let stock: { remainingByRawMaterialKind: jest.Mock }; let handlers: AutomationEventHandlers; const asTx = () => tx as unknown as Prisma.TransactionClient; beforeEach(() => { tx = Object.fromEntries( RECORD_MODELS.map((model) => [ model, { findUnique: jest.fn().mockResolvedValue(null) }, ]), ) as Record; automation = { evaluateInspectionIn: jest.fn().mockResolvedValue(undefined), evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined), evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined), evaluateFeedingIn: jest.fn().mockResolvedValue(undefined), evaluateColonyHealthIn: jest.fn().mockResolvedValue(undefined), evaluateRawMaterialLowIn: jest.fn().mockResolvedValue(undefined), }; stock = { remainingByRawMaterialKind: jest.fn() }; handlers = new AutomationEventHandlers( automation as unknown as AutomationService, stock as unknown as StockService, ); }); it('registers the stable handler ids and event types', () => { const registered = (method: keyof AutomationEventHandlers) => domainEventHandlerMetadata( Reflect.get(AutomationEventHandlers.prototype, method), ); expect(registered('inspectionRules')).toEqual([ { types: ['InspectionRecorded'], handlerId: 'automation.inspection-rules', }, ]); expect(registered('varroaThreshold')).toEqual([ { types: ['VarroaMeasured', 'VarroaCorrected'], handlerId: 'automation.varroa-threshold', }, ]); expect(registered('treatmentFollowUp')).toEqual([ { types: ['TreatmentRecorded'], handlerId: 'automation.treatment-follow-up', }, ]); expect(registered('feedingFollowUp')).toEqual([ { types: ['FeedingRecorded'], handlerId: 'automation.feeding-follow-up' }, ]); expect(registered('colonyHealth')).toEqual([ { types: ['ColonyStatusChanged'], handlerId: 'automation.colony-health' }, ]); expect(registered('rawMaterialLow')).toEqual([ { types: ['StockChanged'], handlerId: 'automation.raw-material-low' }, ]); }); describe('record handlers', () => { const cases = [ [ 'inspectionRules', 'InspectionRecorded', 'inspection', 'evaluateInspectionIn', ], [ 'varroaThreshold', 'VarroaMeasured', 'varroaMeasurement', 'evaluateVarroaMeasurementIn', ], [ 'varroaThreshold', 'VarroaCorrected', 'varroaMeasurement', 'evaluateVarroaMeasurementIn', ], [ 'treatmentFollowUp', 'TreatmentRecorded', 'treatment', 'evaluateTreatmentIn', ], ['feedingFollowUp', 'FeedingRecorded', 'feeding', 'evaluateFeedingIn'], ] as const; const run = ( method: (typeof cases)[number][0], type: (typeof cases)[number][1], ) => handlers[method]( envelope(type, 'record-1', { colonyId: 'colony-1' }) as never, asTx(), ); it.each(cases)( 'handlers load the record via tx and evaluate it: %s on %s', async (method, type, model, core) => { const record = { id: 'record-1', colonyId: 'colony-1' }; tx[model].findUnique.mockResolvedValueOnce(record); await run(method, type); expect(tx[model].findUnique).toHaveBeenCalledWith({ where: { id: 'record-1' }, }); expect(automation[core]).toHaveBeenCalledWith(tx, record); }, ); it.each(cases)( 'handlers load the record via tx and skip a missing one: %s on %s', async (method, type, model, core) => { await run(method, type); expect(tx[model].findUnique).toHaveBeenCalledTimes(1); expect(automation[core]).not.toHaveBeenCalled(); }, ); it('lets an evaluation failure propagate, so the delivery is retried', async () => { tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' }); automation.evaluateInspectionIn.mockRejectedValue( new Error('notifications down'), ); await expect( run('inspectionRules', 'InspectionRecorded'), ).rejects.toThrow('notifications down'); }); }); describe('colony-health', () => { const from = { status: 'ACTIVE', healthStatus: 'AVERAGE' } as const; const to = { status: 'ACTIVE', healthStatus: 'CRITICAL' } as const; const event = () => envelope('ColonyStatusChanged', 'colony-1', { from, to }); it('hands the transition of the event to evaluateColonyHealthIn', async () => { tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); await handlers.colonyHealth(event(), asTx()); expect(tx.colony.findUnique).toHaveBeenCalledWith({ where: { id: 'colony-1' }, select: { id: true }, }); // The rule decides on the snapshots of the event, not on the colony // as it is now: a later change must not hide the transition. expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith( tx, 'colony-1', from, to, ); }); it('skips a colony that no longer exists', async () => { await handlers.colonyHealth(event(), asTx()); expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled(); }); }); describe('raw-material-low', () => { const stockChanged = (rawMaterialKinds: ('BEESWAX' | 'PROPOLIS')[]) => envelope('StockChanged', 'movement-1', { bottlingBatchIds: [], productIds: [], rawMaterialKinds, }); it('evaluates every touched kind once with what is left of it', async () => { stock.remainingByRawMaterialKind.mockImplementation((kind: string) => Promise.resolve(kind === 'BEESWAX' ? 400 : 75), ); await handlers.rawMaterialLow( stockChanged(['BEESWAX', 'PROPOLIS', 'BEESWAX']), asTx(), ); expect(stock.remainingByRawMaterialKind).toHaveBeenCalledTimes(2); expect(automation.evaluateRawMaterialLowIn.mock.calls).toEqual([ [tx, 'BEESWAX', 400], [tx, 'PROPOLIS', 75], ]); }); it('does nothing for a change without raw material', async () => { await handlers.rawMaterialLow(stockChanged([]), asTx()); expect(stock.remainingByRawMaterialKind).not.toHaveBeenCalled(); expect(automation.evaluateRawMaterialLowIn).not.toHaveBeenCalled(); }); }); });