import { Prisma } from '../../generated/prisma'; import type { AnyDomainEventEnvelope, DomainEventEnvelope, DomainEventType, } from '../domain-events/domain-event-types'; import { honeyBatchInclude } from '../honey-batches/honey-batch-include'; import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; import { DOMAIN_EVENT_WEBHOOKS, WebhookEventHandlers, } from './webhook.handlers'; import type { WebhooksService } from './webhooks.service'; type RecordModel = | 'inspection' | 'varroaMeasurement' | 'treatment' | 'feeding' | 'harvest' | 'honeyBatch' | 'queen' | 'task'; const RECORD_MODELS: readonly RecordModel[] = [ 'inspection', 'varroaMeasurement', 'treatment', 'feeding', 'harvest', 'honeyBatch', 'queen', 'task', ]; 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 AnyDomainEventEnvelope; } describe('WebhookEventHandlers', () => { let tx: Record; let webhooks: { enqueueDeliveries: jest.Mock }; let handlers: WebhookEventHandlers; const forward = (event: AnyDomainEventEnvelope) => handlers.forward(event, tx as unknown as Prisma.TransactionClient); beforeEach(() => { tx = Object.fromEntries( RECORD_MODELS.map((model) => [ model, { findUnique: jest.fn().mockResolvedValue(null) }, ]), ) as Record; webhooks = { enqueueDeliveries: jest.fn().mockResolvedValue(1) }; handlers = new WebhookEventHandlers(webhooks as unknown as WebhooksService); }); it('maps exactly these domain events to webhook events', () => { expect(DOMAIN_EVENT_WEBHOOKS).toEqual({ InspectionRecorded: 'inspection.created', VarroaMeasured: 'varroa.measured', TreatmentRecorded: 'treatment.created', FeedingRecorded: 'feeding.created', HarvestRecorded: 'harvest.created', BatchBottled: 'batch.updated', BottlingCorrected: 'batch.updated', HoneyBatchRecorded: 'batch.created', StockChanged: 'stock.changed', TaskCreated: 'task.created', TaskCompleted: 'task.completed', QueenAssigned: 'queen.replaced', }); }); it('none of the herd and voice events is forwarded to webhooks', async () => { const herdEvents = [ ['ColonyCreated', { apiaryId: 'a1' }], ['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }], ['ApiaryChanged', { change: 'LOCATION_CHANGED' }], ['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }], [ 'HiveChanged', { colonyId: 'c1', version: 2, source: 'CARD', inspectionId: null }, ], ['VoiceCaptureReady', { captureId: 'v1', colonyId: 'c1' }], ['VoiceCaptureFailed', { captureId: 'v1', code: 'NO_SPEECH' }], ] as const; for (const [type, payload] of herdEvents) { expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type); await forward(envelope(type, 'x1', payload as never)); } expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); }); it('registers forward as "webhooks.forward" for every mapped type', () => { // The decorator stores its metadata on the method itself. const method = Reflect.get(WebhookEventHandlers.prototype, 'forward'); expect(domainEventHandlerMetadata(method as object)).toEqual([ { types: Object.keys(DOMAIN_EVENT_WEBHOOKS), handlerId: 'webhooks.forward', }, ]); }); it('enqueues inspection.created with the inspection as data', async () => { const inspection = { id: 'i1', colonyId: 'c1', notes: 'calm' }; tx.inspection.findUnique.mockResolvedValueOnce(inspection); await forward(envelope('InspectionRecorded', 'i1', { colonyId: 'c1' })); expect(tx.inspection.findUnique).toHaveBeenCalledWith({ where: { id: 'i1' }, }); // Through the delivery transaction: the rows commit with the delivery. expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'inspection.created', inspection, ); }); it.each([ ['VarroaMeasured', 'varroaMeasurement', 'varroa.measured'], ['TreatmentRecorded', 'treatment', 'treatment.created'], ['FeedingRecorded', 'feeding', 'feeding.created'], ] as const)( 'enqueues %s as %s record under %s', async (type, model, webhookEvent) => { const record = { id: 'r1', colonyId: 'c1' }; tx[model].findUnique.mockResolvedValueOnce(record); await forward(envelope(type, 'r1', { colonyId: 'c1' })); expect(tx[model].findUnique).toHaveBeenCalledWith({ where: { id: 'r1' }, }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, webhookEvent, record, ); }, ); it.each([ ['TaskCreated', 'task.created'], ['TaskCompleted', 'task.completed'], ] as const)('enqueues %s as %s with the task', async (type, webhookEvent) => { const task = { id: 't1', title: 'Varroa zählen' }; tx.task.findUnique.mockResolvedValueOnce(task); await forward( envelope(type, 't1', { relatedType: 'COLONY', relatedId: 'c1', dueDate: null, }), ); expect(tx.task.findUnique).toHaveBeenCalledWith({ where: { id: 't1' } }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, webhookEvent, task, ); }); it('QueenAssigned forwards queen.replaced with the queen', async () => { const queen = { id: 'q1', code: 'Q-001' }; tx.queen.findUnique.mockResolvedValueOnce(queen); await forward( envelope('QueenAssigned', 'q1', { colonyId: 'c1', historyId: 'h1', assignedOn: '2026-10-14', previousColonyId: null, }), ); expect(tx.queen.findUnique).toHaveBeenCalledWith({ where: { id: 'q1' } }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'queen.replaced', queen, ); }); it('enqueues harvest.created with the harvest and its colonies', async () => { const harvest = { id: 'h1', colonies: [{ colonyId: 'c1', colony: { id: 'c1', code: 'V-1' } }], }; tx.harvest.findUnique.mockResolvedValueOnce(harvest); await forward( envelope('HarvestRecorded', 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }), ); expect(tx.harvest.findUnique).toHaveBeenCalledWith({ where: { id: 'h1' }, include: { colonies: { include: { colony: true } } }, }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'harvest.created', harvest, ); }); it.each([ ['BatchBottled', { honeyBatchId: 'hb1' }], ['BottlingCorrected', { honeyBatchId: 'hb1', removed: true }], ] as const)( 'enqueues batch.updated with the honey batch for %s', async (type, payload) => { const honeyBatch = { id: 'hb1', lotNumber: 'L-2026-001' }; tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch); // The aggregate is the bottling batch; the data is its honey batch, // which still exists after a bottling was removed. await forward( envelope( type, 'bb1', payload as DomainEventEnvelope<'BottlingCorrected'>['payload'], ), ); expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({ where: { id: 'hb1' }, }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'batch.updated', honeyBatch, ); }, ); it('enqueues batch.created with the honey batch and its relations for HoneyBatchRecorded', async () => { const honeyBatch = { id: 'hb1', batchNumber: 'H-1', extraction: {} }; tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch); await forward( envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }), ); expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({ where: { id: 'hb1' }, include: honeyBatchInclude, }); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'batch.created', honeyBatch, ); }); it('sends nothing for HoneyBatchRecorded when the batch is gone', async () => { tx.honeyBatch.findUnique.mockResolvedValueOnce(null); await forward( envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }), ); expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); }); it('sends no webhook for a corrected honey batch (as before)', async () => { await forward( envelope('HoneyBatchCorrected', 'hb1', { extractionId: 'e1' }), ); expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); }); it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => { await forward( envelope('StockChanged', 'm1', { bottlingBatchIds: ['b1', 'b1', 'b2'], productIds: ['p1'], rawMaterialKinds: ['BEESWAX'], }), ); expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( tx, 'stock.changed', { bottlingBatchIds: ['b1', 'b2'], productIds: ['p1'] }, ); RECORD_MODELS.forEach((model) => expect(tx[model].findUnique).not.toHaveBeenCalled(), ); }); it('enqueues nothing for StockChanged that only touches raw material', async () => { await forward( envelope('StockChanged', 'm1', { bottlingBatchIds: [], productIds: [], rawMaterialKinds: ['PROPOLIS'], }), ); expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); }); it('skips a record that no longer exists', async () => { tx.inspection.findUnique.mockResolvedValueOnce(null); await forward(envelope('InspectionRecorded', 'gone', { colonyId: 'c1' })); expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); }); it('enqueues nothing for a type without a webhook', async () => { await forward(envelope('TaskDue', 't1', { dueOn: '2026-10-13' })); expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled(); RECORD_MODELS.forEach((model) => expect(tx[model].findUnique).not.toHaveBeenCalled(), ); }); // The dispatcher rolls the delivery transaction back and retries. it('fails when the deliveries cannot be enqueued', async () => { tx.task.findUnique.mockResolvedValueOnce({ id: 't1' }); webhooks.enqueueDeliveries.mockRejectedValueOnce(new Error('db down')); await expect( forward( envelope('TaskCompleted', 't1', { relatedType: 'GENERAL', relatedId: null, }), ), ).rejects.toThrow('db down'); }); });