import type { DbClient } from '../prisma/db-client'; import type { Prisma } from '../../generated/prisma'; import type { NotificationsService } from '../notifications/notifications.service'; import { WithdrawalCheckService, type WithdrawalConflict, } from './withdrawal-check.service'; interface TreatmentRow { id: string; colonyId: string; product: string; date: Date; startDate: Date | null; endDate: Date | null; withdrawalPeriodDays: number | null; } const treatment = (overrides: Partial): TreatmentRow => ({ id: 't1', colonyId: 'c1', product: 'Oxuvar', date: new Date('2026-07-31T00:00:00.000Z'), startDate: null, endDate: null, withdrawalPeriodDays: 20, ...overrides, }); const conflict = ( overrides: Partial = {}, ): WithdrawalConflict => ({ harvestId: 'h1', harvestedOn: '2026-08-10', colonyId: 'c1', treatmentId: 't1', product: 'Oxuvar', untilDay: '2026-08-20', ...overrides, }); describe('WithdrawalCheckService', () => { // 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 db: { harvest: { findMany: jest.Mock }; treatment: { findMany: jest.Mock; findUnique: jest.Mock }; colony: { findMany: jest.Mock }; honeyBatch: { findUnique: jest.Mock }; extractionHarvest: { findMany: jest.Mock }; bottlingBatch: { findMany: jest.Mock }; }; let notifications: { recordForPermission: jest.Mock }; let service: WithdrawalCheckService; beforeEach(() => { db = { harvest: { findMany: jest.fn().mockResolvedValue([]) }, treatment: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn().mockResolvedValue(null), }, colony: { findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]), }, honeyBatch: { findUnique: jest.fn() }, extractionHarvest: { findMany: jest.fn() }, bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) }, }; notifications = { recordForPermission: jest.fn().mockResolvedValue([]) }; service = new WithdrawalCheckService( notifications as unknown as NotificationsService, ); }); const asDb = () => db as unknown as DbClient; const harvestOn = (id: string, day: string, colonyIds: string[]) => ({ id, date: new Date(`${day}T00:00:00.000Z`), colonies: colonyIds.map((colonyId) => ({ colonyId })), }); describe('conflictsForHarvests', () => { it('finds the treatment whose window includes the harvest date per colony', async () => { db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-08-10', ['c1', 'c2']), ]); db.treatment.findMany.mockResolvedValue([ treatment({ id: 't1', colonyId: 'c1' }), // Window ended before the harvest: no conflict for c2. treatment({ id: 't2', colonyId: 'c2', date: new Date('2026-06-01T00:00:00.000Z'), withdrawalPeriodDays: 10, }), ]); const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); expect(conflicts).toEqual([conflict()]); }); it('treats the first harvestable day as free and the treatment day as covered', async () => { // t1: 2026-07-31 + 20 days = first harvestable day 2026-08-20. db.harvest.findMany.mockResolvedValue([ harvestOn('h-start', '2026-07-31', ['c1']), harvestOn('h-last', '2026-08-19', ['c1']), harvestOn('h-free', '2026-08-20', ['c1']), harvestOn('h-before', '2026-07-30', ['c1']), ]); db.treatment.findMany.mockResolvedValue([treatment({})]); const conflicts = await service.conflictsForHarvests(asDb(), [ 'h-start', 'h-last', 'h-free', 'h-before', ]); expect(conflicts.map((c) => c.harvestId)).toEqual(['h-start', 'h-last']); }); it('starts the window at startDate when it lies before the treatment date', async () => { db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-07-25', ['c1']), ]); db.treatment.findMany.mockResolvedValue([ treatment({ startDate: new Date('2026-07-24T00:00:00.000Z') }), ]); const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); expect(conflicts).toHaveLength(1); }); it('reports one conflict per harvest and colony, the one that lasts longest', async () => { db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-08-10', ['c1']), ]); db.treatment.findMany.mockResolvedValue([ treatment({ id: 't-short', withdrawalPeriodDays: 12 }), treatment({ id: 't-long', withdrawalPeriodDays: 30, product: 'Apivar', }), ]); const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); expect(conflicts).toEqual([ conflict({ treatmentId: 't-long', product: 'Apivar', untilDay: '2026-08-30', }), ]); }); it('ignores treatments without a withdrawal period and colonies without any', async () => { db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-08-10', ['c1']), harvestOn('h2', '2026-08-10', []), ]); db.treatment.findMany.mockResolvedValue([ treatment({ withdrawalPeriodDays: null }), ]); expect(await service.conflictsForHarvests(asDb(), ['h1', 'h2'])).toEqual( [], ); }); it('answers at once without ids and does not read anything', async () => { expect(await service.conflictsForHarvests(asDb(), [])).toEqual([]); expect(db.harvest.findMany).not.toHaveBeenCalled(); }); it('compares the Berlin calendar day of the harvest', async () => { // 2026-08-19T22:30Z is 00:30 on 2026-08-20 in Berlin: already free. db.harvest.findMany.mockResolvedValue([ { id: 'h1', date: new Date('2026-08-19T22:30:00.000Z'), colonies: [{ colonyId: 'c1' }], }, ]); db.treatment.findMany.mockResolvedValue([treatment({})]); expect(await service.conflictsForHarvests(asDb(), ['h1'])).toEqual([]); }); }); describe('conflictsForTreatment', () => { it('finds the harvests of the colony inside the window, entered before the treatment', async () => { db.treatment.findUnique.mockResolvedValue(treatment({})); // Candidates (date range) first, then the harvests re-read for the check. db.harvest.findMany .mockResolvedValueOnce([ { id: 'h-in', date: new Date('2026-08-10T00:00:00.000Z') }, { id: 'h-free', date: new Date('2026-08-20T00:00:00.000Z') }, { id: 'h-before', date: new Date('2026-07-30T00:00:00.000Z') }, ]) .mockResolvedValueOnce([harvestOn('h-in', '2026-08-10', ['c1', 'c2'])]); db.treatment.findMany.mockResolvedValue([treatment({})]); const conflicts = await service.conflictsForTreatment(asDb(), 't1'); expect(db.harvest.findMany).toHaveBeenNthCalledWith( 1, expect.objectContaining({ where: { colonies: { some: { colonyId: 'c1' } }, date: { gte: new Date('2026-07-30T00:00:00.000Z'), lt: new Date('2026-08-21T00:00:00.000Z'), }, }, }), ); expect(db.harvest.findMany).toHaveBeenNthCalledWith( 2, expect.objectContaining({ where: { id: { in: ['h-in'] } } }), ); // Only the colony of the treatment. expect(conflicts).toEqual([conflict({ harvestId: 'h-in' })]); }); it('answers nothing without a withdrawal span or for an unknown treatment', async () => { db.treatment.findUnique.mockResolvedValueOnce(null); expect(await service.conflictsForTreatment(asDb(), 'x')).toEqual([]); db.treatment.findUnique.mockResolvedValueOnce( treatment({ withdrawalPeriodDays: null }), ); expect(await service.conflictsForTreatment(asDb(), 't1')).toEqual([]); expect(db.harvest.findMany).not.toHaveBeenCalled(); }); }); describe('conflictsForHoneyBatch', () => { it('follows batch, extraction and harvests', async () => { db.honeyBatch.findUnique.mockResolvedValue({ extraction: { harvests: [{ harvestId: 'h1' }, { harvestId: 'h2' }] }, }); db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-08-10', ['c1']), ]); db.treatment.findMany.mockResolvedValue([treatment({})]); const conflicts = await service.conflictsForHoneyBatch(asDb(), 'b1'); expect(db.harvest.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } }, }), ); expect(conflicts).toEqual([conflict()]); }); it('answers nothing for an unknown batch', async () => { db.honeyBatch.findUnique.mockResolvedValue(null); expect(await service.conflictsForHoneyBatch(asDb(), 'nope')).toEqual([]); }); }); describe('conflictsForExtraction', () => { it('checks the harvests of the extraction', async () => { db.extractionHarvest.findMany.mockResolvedValue([ { harvestId: 'h1' }, { harvestId: 'h2' }, ]); db.harvest.findMany.mockResolvedValue([ harvestOn('h1', '2026-08-10', ['c1']), ]); db.treatment.findMany.mockResolvedValue([treatment({})]); const conflicts = await service.conflictsForExtraction(asDb(), 'e1'); expect(db.extractionHarvest.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { extractionId: 'e1' } }), ); expect(db.harvest.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } } }), ); expect(conflicts).toEqual([conflict()]); }); it('answers nothing for an extraction without harvests', async () => { db.extractionHarvest.findMany.mockResolvedValue([]); expect(await service.conflictsForExtraction(asDb(), 'e1')).toEqual([]); }); }); describe('warningsFor', () => { it('words the warning with colony code, product and the end day', async () => { const warnings = await service.warningsFor(asDb(), [conflict()]); expect(warnings).toEqual([ { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', message: 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', refs: [ { type: 'Colony', id: 'c1' }, { type: 'Treatment', id: 't1' }, { type: 'Harvest', id: 'h1' }, ], }, ]); }); it('warns once per colony and treatment when several harvests feed the batch', async () => { const warnings = await service.warningsFor(asDb(), [ conflict({ harvestId: 'h1' }), conflict({ harvestId: 'h2' }), conflict({ harvestId: 'h3', treatmentId: 't2', product: 'Apivar' }), ]); expect(warnings.map((w) => w.message)).toEqual([ 'B3: Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', 'B3: Wartezeit nach Apivar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', ]); expect(warnings[0].refs).toEqual([ { type: 'Colony', id: 'c1' }, { type: 'Treatment', id: 't1' }, { type: 'Harvest', id: 'h1' }, { type: 'Harvest', id: 'h2' }, ]); }); it('answers nothing without reading when there is no conflict', async () => { expect(await service.warningsFor(asDb(), [])).toEqual([]); expect(db.colony.findMany).not.toHaveBeenCalled(); }); }); describe('notifyConflictsIn', () => { const tx = () => db as unknown as Prisma.TransactionClient; it('notifies readers of harvests and treatments once per harvest and colony', async () => { notifications.recordForPermission.mockResolvedValue([{ id: 'n1' }]); const count = await service.notifyConflictsIn(tx(), [conflict()]); expect(count).toBe(1); expect(notifications.recordForPermission).toHaveBeenCalledWith( ['harvests:read', 'treatments:read'], { priority: 'WARNUNG', category: 'WITHDRAWAL', message: 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig.', relatedType: 'Harvest', relatedId: 'h1', link: '/harvests/h1', dedupeKeyPrefix: 'withdrawal-conflict:h1:c1', }, db, ); }); it('names the bottling lots when the harvest was already bottled', async () => { db.bottlingBatch.findMany.mockResolvedValue([ { lotNumber: 'L-2', honeyBatch: { extraction: { harvests: [{ harvestId: 'h1' }] } }, }, { lotNumber: 'L-1', honeyBatch: { extraction: { harvests: [{ harvestId: 'h1' }, { harvestId: 'h9' }], }, }, }, { lotNumber: 'L-other', honeyBatch: { extraction: { harvests: [{ harvestId: 'h9' }] } }, }, ]); await service.notifyConflictsIn(tx(), [conflict()]); const [, input] = notifications.recordForPermission.mock.calls[0] as [ unknown, { message: string; dedupeKeyPrefix: string }, ]; expect(input.message).toBe( 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar bis einschließlich 19.08. – ab 20.08. wieder erntefähig. Bereits abgefüllt: Los L-1, L-2.', ); // Same key as without the lots: one notification per harvest and colony. expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1'); }); it('counts only the rows that were really written', async () => { notifications.recordForPermission .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: 'n1' }, { id: 'n2' }]); const count = await service.notifyConflictsIn(tx(), [ conflict(), conflict({ harvestId: 'h2' }), ]); expect(count).toBe(2); }); it('does nothing without conflicts', async () => { expect(await service.notifyConflictsIn(tx(), [])).toBe(0); expect(notifications.recordForPermission).not.toHaveBeenCalled(); }); it('refuses the root client', async () => { const root = { $connect: jest.fn(), } as unknown as Prisma.TransactionClient; await expect( service.notifyConflictsIn(root, [conflict()]), ).rejects.toThrow('needs the transaction client'); }); }); });