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 type { NotificationsService } from '../notifications/notifications.service'; import type { WithdrawalCheckService, WithdrawalConflict, } from './withdrawal-check.service'; import { WithdrawalEventHandlers } from './withdrawal.handlers'; const conflict: WithdrawalConflict = { harvestId: 'h1', harvestedOn: '2026-08-10', colonyId: 'c1', treatmentId: 't1', product: 'Oxuvar', untilDay: '2026-08-20', }; const envelope = ( type: T, aggregateId: string, payload: Record, ) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope; const treatmentRow = (overrides: Record = {}) => ({ id: 't1', colonyId: 'c1', product: 'Oxuvar', date: new Date('2026-07-31T00:00:00.000Z'), startDate: null, endDate: null, withdrawalPeriodDays: 20, colony: { code: 'B3', status: 'ACTIVE' }, ...overrides, }); describe('WithdrawalEventHandlers', () => { // The texts add the year outside the current one: pin 'today'. beforeAll(() => { jest.useFakeTimers({ now: new Date('2026-06-01T10:00:00Z') }); }); afterAll(() => { jest.useRealTimers(); }); let check: { conflictsForHarvests: jest.Mock; conflictsForHoneyBatch: jest.Mock; conflictsForExtraction: jest.Mock; conflictsForTreatment: jest.Mock; notifyConflictsIn: jest.Mock; }; let notifications: { recordForPermission: jest.Mock }; let tx: { treatment: { findUnique: jest.Mock; findMany: jest.Mock }; automationRule: { findUnique: jest.Mock }; }; let handlers: WithdrawalEventHandlers; beforeEach(() => { check = { conflictsForHarvests: jest.fn().mockResolvedValue([conflict]), conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]), conflictsForExtraction: jest.fn().mockResolvedValue([conflict]), conflictsForTreatment: jest.fn().mockResolvedValue([conflict]), notifyConflictsIn: jest.fn().mockResolvedValue(1), }; notifications = { recordForPermission: jest.fn().mockResolvedValue([]) }; tx = { treatment: { findUnique: jest.fn().mockResolvedValue(treatmentRow()), findMany: jest.fn().mockResolvedValue([]), }, automationRule: { findUnique: jest.fn().mockResolvedValue(null) }, }; handlers = new WithdrawalEventHandlers( check as unknown as WithdrawalCheckService, notifications as unknown as NotificationsService, ); }); const asTx = () => tx as unknown as Prisma.TransactionClient; it('registers the three handlers under their ids', () => { const entries = (method: keyof WithdrawalEventHandlers) => domainEventHandlerMetadata( Object.getOwnPropertyDescriptor( WithdrawalEventHandlers.prototype, method, )?.value as object, ); expect(entries('harvestCheck')).toEqual([ { types: ['HarvestRecorded', 'HarvestCorrected'], handlerId: 'withdrawal.harvest-check', }, ]); expect(entries('bottlingCheck')).toEqual([ { types: ['BatchBottled', 'BottlingCorrected'], handlerId: 'withdrawal.bottling-check', }, ]); expect(entries('treatmentCheck')).toEqual([ { types: ['TreatmentRecorded', 'TreatmentCorrected'], handlerId: 'withdrawal.treatment-check', }, ]); expect(entries('withdrawalEnded')).toEqual([ { types: ['WithdrawalEnded'], handlerId: 'notifications.withdrawal-ended', }, ]); }); describe('harvest check', () => { it.each(['HarvestRecorded', 'HarvestCorrected'] as const)( 'notifies the conflicts of the harvest on %s', async (type) => { await handlers.harvestCheck( envelope(type, 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }), asTx(), ); expect(check.conflictsForHarvests).toHaveBeenCalledWith(tx, ['h1']); expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); }, ); }); describe('extraction check', () => { it('registers under its id', () => { expect( domainEventHandlerMetadata( Object.getOwnPropertyDescriptor( WithdrawalEventHandlers.prototype, 'extractionCheck', )?.value as object, ), ).toEqual([ { types: ['ExtractionRecorded', 'ExtractionCorrected'], handlerId: 'withdrawal.extraction-check', }, ]); }); it.each(['ExtractionRecorded', 'ExtractionCorrected'] as const)( 'notifies the conflicts of the extraction on %s', async (type) => { await handlers.extractionCheck( envelope(type, 'e1', { harvestIds: ['h1'] }), asTx(), ); expect(check.conflictsForExtraction).toHaveBeenCalledWith(tx, 'e1'); expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); }, ); }); describe('treatment check', () => { it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)( 'notifies harvests that already lie in the window of the treatment on %s', async (type) => { await handlers.treatmentCheck( envelope(type, 't1', { colonyId: 'c1' }), asTx(), ); expect(check.conflictsForTreatment).toHaveBeenCalledWith(tx, 't1'); expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); }, ); }); describe('bottling check', () => { it('checks the harvests behind the honey batch on BatchBottled', async () => { await handlers.bottlingCheck( envelope('BatchBottled', 'bb1', { honeyBatchId: 'hb1' }), asTx(), ); expect(check.conflictsForHoneyBatch).toHaveBeenCalledWith(tx, 'hb1'); expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); }); it('checks on a corrected bottling too', async () => { await handlers.bottlingCheck( envelope('BottlingCorrected', 'bb1', { honeyBatchId: 'hb1', removed: false, }), asTx(), ); expect(check.notifyConflictsIn).toHaveBeenCalledTimes(1); }); it('does nothing for a removed bottling', async () => { await handlers.bottlingCheck( envelope('BottlingCorrected', 'bb1', { honeyBatchId: 'hb1', removed: true, }), asTx(), ); expect(check.conflictsForHoneyBatch).not.toHaveBeenCalled(); expect(check.notifyConflictsIn).not.toHaveBeenCalled(); }); }); describe('withdrawal ended', () => { const ended = () => envelope('WithdrawalEnded', 't1', { colonyId: 'c1', endedOn: '2026-08-20', }); it('tells readiness to harvest once, with colony, product and end day', async () => { await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).toHaveBeenCalledWith( ['harvests:create', 'treatments:read'], { priority: 'INFO', category: 'WITHDRAWAL', message: 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', relatedType: 'Colony', relatedId: 'c1', link: '/colonies/c1', dedupeKeyPrefix: 'withdrawal-ended:t1:2026-08-20', }, tx, ); }); it('stays quiet while another window of the colony still runs', async () => { tx.treatment.findMany.mockResolvedValue([ treatmentRow({ id: 't2', date: new Date('2026-08-10T00:00:00.000Z'), withdrawalPeriodDays: 14, }), ]); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('ignores a window of another treatment that has ended or not begun', async () => { tx.treatment.findMany.mockResolvedValue([ // Ended on 2026-08-20 itself: the day is free again. treatmentRow({ id: 't2', date: new Date('2026-07-01T00:00:00.000Z'), withdrawalPeriodDays: 50, }), // Starts after the end day. treatmentRow({ id: 't3', date: new Date('2026-08-21T00:00:00.000Z'), withdrawalPeriodDays: 10, }), ]); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); }); it('reads the other treatments of the colony without the ended one', async () => { await handlers.withdrawalEnded(ended(), asTx()); expect(tx.treatment.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { colonyId: 'c1', id: { not: 't1' }, withdrawalPeriodDays: { not: null }, }, }), ); }); it('is silent when the rule is switched off', async () => { tx.automationRule.findUnique.mockResolvedValue({ id: 'r1', active: false, config: { notify: true }, actionTemplate: null, }); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('is silent when notify is off', async () => { tx.automationRule.findUnique.mockResolvedValue({ id: 'r1', active: true, config: { notify: false }, actionTemplate: null, }); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('is silent for a colony that no longer exists as a living unit', async () => { tx.treatment.findUnique.mockResolvedValue( treatmentRow({ colony: { code: 'B3', status: 'DISSOLVED' } }), ); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('is silent when the treatment is gone or its window moved since the event', async () => { tx.treatment.findUnique.mockResolvedValueOnce(null); await handlers.withdrawalEnded(ended(), asTx()); tx.treatment.findUnique.mockResolvedValueOnce( treatmentRow({ withdrawalPeriodDays: 25 }), ); await handlers.withdrawalEnded(ended(), asTx()); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); }); });