import { PrismaService } from '../prisma/prisma.service'; import { SeasonService } from '../season/season.service'; import { CATCH_UP_DAYS, catchUpEvents, DAILY_EVALUATION_RULES, DailyEvent, inCatchUpWindow, withdrawalEndDay, } from './daily-evaluation.rules'; const DAY_MS = 24 * 60 * 60 * 1000; /** Midnight UTC of a 'YYYY-MM-DD' day. */ const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); /** Every day from `from` to `to`, both included. */ function daysBetween(from: string, to: string): string[] { const days: string[] = []; for (let t = d(from).getTime(); t <= d(to).getTime(); t += DAY_MS) { days.push(new Date(t).toISOString().slice(0, 10)); } return days; } interface FakeRows { treatments?: { id: string; colonyId: string; date: Date; endDate: Date | null; withdrawalPeriodDays: number | null; }[]; tasks?: { id: string; dueDate: Date | null }[]; /** Living colonies, all in one apiary. */ colonies?: { id: string; createdAt: Date }[]; /** Living colonies with their current queen (rows the queen query returns). */ queenedColonies?: { id: string; currentQueen: { id: string; hatchYear: number | null } | null; }[]; /** The latest varroa measurement per colony. */ varroa?: { id: string; colonyId: string; date: Date }[]; /** The latest inspection per colony. */ inspections?: { colonyId: string; date: Date }[]; /** * The ComplianceSettings reference day (MM-TT); null: no settings row. * Without a value a day far from the other tests' days is used. */ tskReferenceDay?: string | null; /** Stored rule configs by trigger type; others read as the built-in setting. */ rules?: Record; } interface FakeDb { db: PrismaService; queryRaw: jest.Mock; taskFindMany: jest.Mock; colonyFindMany: jest.Mock; } /** * Returns the given rows whatever the query filters - the rules decide by * day themselves, the database filters only narrow the candidates. */ function fakeDb(rows: FakeRows): FakeDb { const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []); const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []); const colonyFindMany = jest.fn( ({ select }: { select: Record }) => Promise.resolve( 'currentQueen' in select ? (rows.queenedColonies ?? []) : (rows.colonies ?? []).map((colony) => ({ ...colony, apiaryId: 'apiary-1', })), ), ); const db = { $queryRaw: queryRaw, task: { findMany: taskFindMany }, colony: { findMany: colonyFindMany }, varroaMeasurement: { findMany: jest.fn().mockResolvedValue(rows.varroa ?? []), }, inspection: { groupBy: jest.fn().mockResolvedValue( (rows.inspections ?? []).map(({ colonyId, date }) => ({ colonyId, _max: { date }, })), ), }, complianceSettings: { findUnique: jest.fn().mockResolvedValue( rows.tskReferenceDay === null ? null : { id: 'singleton', tskReferenceDay: rows.tskReferenceDay ?? '10-10', }, ), // A missing row is created with the schema default. upsert: jest .fn() .mockResolvedValue({ id: 'singleton', tskReferenceDay: '01-01' }), }, // No stored phenology: every apiary gets the calendar state. apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) }, automationRule: { findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => { const config = rows.rules?.[where.triggerType]; return Promise.resolve( config === undefined ? null : { id: 'rule-1', active: true, actionTemplate: null, config }, ); }), }, } as unknown as PrismaService; return { db, queryRaw, taskFindMany, colonyFindMany }; } const seasons = new SeasonService(); /** One job run on `day`: every rule's events in the catch-up window. */ async function eventsOn(db: PrismaService, day: string): Promise { const events: DailyEvent[] = []; for (const rule of DAILY_EVALUATION_RULES) { events.push( ...catchUpEvents( await rule.occurrences(db, d(day), { seasons, day }), d(day), ), ); } return events; } /** * Runs the evaluation on each of `days` into a store keyed like the unique * DomainEvent.dedupeKey: an event whose key exists is skipped, as * recordAll's createMany(skipDuplicates) does. */ async function runDaily( db: PrismaService, days: readonly string[], store = new Map(), ): Promise> { for (const day of days) { for (const event of await eventsOn(db, day)) { if (!store.has(event.dedupeKey)) { store.set(event.dedupeKey, event); } } } return store; } const keysOf = (store: Map, type: string): string[] => [...store.keys()].filter((key) => key.startsWith(`${type}:`)); describe('daily evaluation rules', () => { it('keeps the catch-up window of the backbone plan', () => { expect(CATCH_UP_DAYS).toBe(7); }); it('runs one rule per time-based event type', () => { expect(DAILY_EVALUATION_RULES.map((rule) => rule.type)).toEqual([ 'WithdrawalEnded', 'TaskDue', 'QueenAged', 'VarroaMeasurementDue', 'InspectionOverdue', 'TskReferenceDayReached', ]); }); describe('TskReferenceDayReached', () => { const only = (events: DailyEvent[]) => events.filter((event) => event.type === 'TskReferenceDayReached'); it('emits TskReferenceDayReached on the configured day', async () => { const { db } = fakeDb({ tskReferenceDay: '03-15' }); expect(only(await eventsOn(db, '2027-03-15'))).toEqual([ { type: 'TskReferenceDayReached', aggregateType: 'Compliance', aggregateId: 'tsk:2027', payload: { year: 2027, referenceDay: '2027-03-15' }, dedupeKey: 'TskReferenceDayReached:tsk:2027:2027-03-15', }, ]); expect(only(await eventsOn(db, '2027-03-14'))).toEqual([]); }); it('catches up within 7 days exactly once', async () => { const { db } = fakeDb({ tskReferenceDay: '03-15' }); const store = await runDaily(db, daysBetween('2027-03-15', '2027-03-22')); expect(keysOf(store, 'TskReferenceDayReached')).toEqual([ 'TskReferenceDayReached:tsk:2027:2027-03-15', ]); expect(only(await eventsOn(db, '2027-03-23'))).toEqual([]); }); it("covers the previous year's day in early January", async () => { const { db } = fakeDb({ tskReferenceDay: '12-30' }); const events = only(await eventsOn(db, '2027-01-03')); expect(events.map((event) => event.payload)).toEqual([ { year: 2026, referenceDay: '2026-12-30' }, ]); }); it('creates the settings row with its default 01-01 when it is missing', async () => { const { db } = fakeDb({ tskReferenceDay: null }); const events = only(await eventsOn(db, '2027-01-04')); expect(events.map((event) => event.payload)).toEqual([ { year: 2027, referenceDay: '2027-01-01' }, ]); }); }); describe('withdrawalEndDay', () => { it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () => expect( withdrawalEndDay({ date: d('2026-09-28'), endDate: d('2026-10-01'), withdrawalPeriodDays: 14, }), ).toBe('2026-10-15')); it('counts from the treatment day when the treatment has no end date', () => expect( withdrawalEndDay({ date: d('2026-09-28'), endDate: null, withdrawalPeriodDays: 14, }), ).toBe('2026-10-12')); it('ends on the treatment day itself without a waiting time and uses the Berlin day, ignoring the time of day', () => expect( withdrawalEndDay({ date: new Date('2026-09-28T22:15:00+02:00'), endDate: null, withdrawalPeriodDays: 0, }), ).toBe('2026-09-28')); it('has no end when the treatment has no withdrawal period', () => expect( withdrawalEndDay({ date: d('2026-09-28'), endDate: d('2026-10-01'), withdrawalPeriodDays: null, }), ).toBeNull()); }); describe('inCatchUpWindow', () => { it('spans today and the 7 days before it', () => { const today = d('2026-06-10'); expect(inCatchUpWindow('2026-06-03', today)).toBe(true); expect(inCatchUpWindow('2026-06-07', today)).toBe(true); expect(inCatchUpWindow('2026-06-10', today)).toBe(true); expect(inCatchUpWindow('2026-06-02', today)).toBe(false); expect(inCatchUpWindow('2026-06-11', today)).toBe(false); }); it('works across a year boundary', () => { expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true); expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false); }); }); it('builds each event from its rule: aggregate, payload and daily dedupe key', async () => { const { db } = fakeDb({ treatments: [ { id: 'treatment-1', colonyId: 'colony-1', date: d('2026-05-15'), endDate: d('2026-05-20'), withdrawalPeriodDays: 5, }, ], tasks: [{ id: 'task-1', dueDate: new Date('2026-05-27T09:00:00Z') }], colonies: [ // Measured and inspected: the intervals count from the records. { id: 'colony-1', createdAt: d('2026-03-01') }, // Never measured or inspected: they count from its creation. { id: 'colony-2', createdAt: d('2026-04-12') }, ], varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }], }); // 2026-05-29 is SCHWARMZEIT (inspection every 7 days). expect(await eventsOn(db, '2026-05-29')).toEqual([ { type: 'WithdrawalEnded', aggregateType: 'Treatment', aggregateId: 'treatment-1', payload: { colonyId: 'colony-1', endedOn: '2026-05-25' }, dedupeKey: 'WithdrawalEnded:treatment-1:2026-05-25', }, { type: 'TaskDue', aggregateType: 'Task', aggregateId: 'task-1', payload: { dueOn: '2026-05-27' }, dedupeKey: 'TaskDue:task-1:2026-05-27', }, // colony-2: 2026-04-12 + 28 = 2026-05-10, outside the window. { type: 'VarroaMeasurementDue', aggregateType: 'Colony', aggregateId: 'colony-1', payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01', kind: 'INTERVAL', anchor: 'm-1', }, dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29', }, // colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after. // colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window. { type: 'InspectionOverdue', aggregateType: 'Colony', aggregateId: 'colony-1', payload: { dueOn: '2026-05-27', lastInspectedOn: '2026-05-20', firstOfYear: false, }, dedupeKey: 'InspectionOverdue:colony-1:2026-05-28', }, ]); }); it('catches up occurrences of the last 7 days exactly once', async () => { // The job last ran on 2026-06-01 and is back on 2026-06-10 (CronJob // down for a week). Then it runs daily again. const { db } = fakeDb({ treatments: [ // Ends 2026-06-02: 8 days before the comeback, outside the window. { id: 'missed', colonyId: 'colony-1', date: d('2026-05-19'), endDate: null, withdrawalPeriodDays: 14, }, // Ends 2026-06-03: 7 days before the comeback, caught up. { id: 'caught-up', colonyId: 'colony-1', date: d('2026-05-20'), endDate: null, withdrawalPeriodDays: 14, }, ], tasks: [ { id: 'due-while-down', dueDate: d('2026-06-06') }, { id: 'due-later', dueDate: d('2026-06-14') }, ], }); const store = await runDaily(db, ['2026-06-01']); expect([...store.keys()]).toEqual([]); const comeback = await eventsOn(db, '2026-06-10'); expect(comeback.map((event) => event.dedupeKey)).toEqual([ 'WithdrawalEnded:caught-up:2026-06-03', 'TaskDue:due-while-down:2026-06-06', ]); // Every later run within the window repeats the same keys, so the // unique dedupeKey keeps one event per occurrence. await runDaily(db, daysBetween('2026-06-10', '2026-06-30'), store); expect([...store.keys()].sort()).toEqual( [ 'TaskDue:due-later:2026-06-14', 'TaskDue:due-while-down:2026-06-06', 'WithdrawalEnded:caught-up:2026-06-03', ].sort(), ); // Once the window has passed, the occurrence is no longer produced. expect(await eventsOn(db, '2026-06-22')).toEqual([]); }); describe('QueenAged', () => { const queen = (hatchYear: number | null) => ({ id: 'queen-1', hatchYear, }); it('fires on the job day, once per queen and year under the January 1 key', async () => { const { db } = fakeDb({ queenedColonies: [ { id: 'colony-1', currentQueen: queen(2024) }, { id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } }, { id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } }, ], }); expect(await eventsOn(db, '2026-09-09')).toEqual([ { type: 'QueenAged', aggregateType: 'Queen', aggregateId: 'queen-1', payload: { hatchYear: 2024, colonyId: 'colony-1' }, dedupeKey: 'QueenAged:queen-1:2026-01-01', }, ]); }); it('QueenAged is not limited to January 1–8', async () => { const { db } = fakeDb({ queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }], }); for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) { expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([ 'QueenAged:queen-1:2026-01-01', ]); } }); it('QueenAged uses queenMaxAgeYears', async () => { const queened = [{ id: 'colony-1', currentQueen: queen(2025) }]; const { db: byDefault } = fakeDb({ queenedColonies: queened }); const { db: stricter, colonyFindMany } = fakeDb({ queenedColonies: queened, rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } }, }); expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]); expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1); expect(colonyFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } }, }) as object, }), ); }); it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => { // The TSK day lies outside every window this test runs. const { db, colonyFindMany } = fakeDb({ tskReferenceDay: '03-03' }); // The queen (hatched 2024) is assigned to the colony on 2026-06-15. let assigned = false; colonyFindMany.mockImplementation( ({ select }: { select: Record }) => Promise.resolve( 'currentQueen' in select && assigned ? [{ id: 'colony-1', currentQueen: queen(2024) }] : [], ), ); const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14')); expect([...store.keys()]).toEqual([]); assigned = true; expect( (await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey), ).toEqual(['QueenAged:queen-1:2026-01-01']); await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store); expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']); // The next year is a new anchor. await runDaily(db, ['2027-01-02'], store); expect([...store.keys()]).toEqual([ 'QueenAged:queen-1:2026-01-01', 'QueenAged:queen-1:2027-01-01', ]); }); }); describe('InspectionOverdue', () => { it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], // SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05. inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }], }); expect( (await eventsOn(db, '2026-05-05')).filter( (event) => event.type === 'InspectionOverdue', ), ).toEqual([]); expect( (await eventsOn(db, '2026-05-06')).filter( (event) => event.type === 'InspectionOverdue', ), ).toEqual([ { type: 'InspectionOverdue', aggregateType: 'Colony', aggregateId: 'colony-1', payload: { dueOn: '2026-05-05', lastInspectedOn: '2026-04-28', firstOfYear: false, }, dedupeKey: 'InspectionOverdue:colony-1:2026-05-06', }, ]); }); it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => { // Last inspected in autumn: the next one is due in the winter. const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }], }); const winter = await runDaily( db, daysBetween('2026-01-01', '2026-03-19'), ); expect([...winter.keys()]).toEqual([]); // FRUEHJAHR begins 2026-03-20: due that day, overdue the day after. const spring = await runDaily( db, daysBetween('2026-03-20', '2026-04-10'), ); expect(keysOf(spring, 'InspectionOverdue')).toEqual([ 'InspectionOverdue:colony-1:2026-03-21', ]); expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject( { payload: { dueOn: '2026-03-20', lastInspectedOn: '2025-10-20', firstOfYear: true, }, }, ); }); it('emits nothing for a due date more than a week back (one event per due date)', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }], }); const inspections = async (day: string) => (await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue'); // Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13. expect(await inspections('2026-05-13')).toHaveLength(1); // Due + 9 days. expect(await inspections('2026-05-14')).toEqual([]); }); it('keeps the same dedupe key on consecutive days while nothing changes', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }], }); const keys = async (day: string) => (await eventsOn(db, day)).map((event) => event.dedupeKey); expect(await keys('2026-05-08')).toEqual( expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']), ); expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08')); }); it('a segment interval change gives a second event with another key', async () => { // Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from // SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20. const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }], }); const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25')); expect(keysOf(store, 'InspectionOverdue')).toEqual([ 'InspectionOverdue:colony-1:2026-06-13', 'InspectionOverdue:colony-1:2026-06-20', ]); }); it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => { // HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 - // but 11-15 is WINTERRUHE, so nothing. const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }], }); expect( keysOf( await runDaily(db, daysBetween('2026-11-10', '2027-03-19')), 'InspectionOverdue', ), ).toEqual([]); const spring = await runDaily( db, daysBetween('2027-03-20', '2027-03-25'), ); expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject( { payload: { dueOn: '2027-03-20', firstOfYear: true } }, ); }); it('emits no inspection event inside WINTERRUHE although the due date passes', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }], }); const winter = await runDaily( db, daysBetween('2026-11-15', '2027-03-19'), ); expect(keysOf(winter, 'InspectionOverdue')).toEqual([]); }); }); describe('VarroaMeasurementDue', () => { it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }], }); const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28')); expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([ 'VarroaMeasurementDue:colony-1:2026-11-15', ]); }); it('is not due in the winter window after a measurement since 11-01', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }], }); const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28')); expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]); }); it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }], // Measured 2026-11-20 (inside the winter window of the year): the // next interval day, 12-18, lies in WINTERRUHE. varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }], }); const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25')); expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([ 'VarroaMeasurementDue:colony-1:2027-03-20', ]); expect( store.get('VarroaMeasurementDue:colony-1:2027-03-20'), ).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } }); }); it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => { const { db } = fakeDb({ colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }], rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } }, }); const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10')); expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([ 'VarroaMeasurementDue:colony-1:2026-05-04', ]); }); }); it('asks the database only for the candidates of the catch-up window', async () => { const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({}); await eventsOn(db, '2026-06-10'); expect(taskFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: { status: { in: ['OPEN', 'IN_PROGRESS'] }, dueDate: { gte: d('2026-06-03'), lt: d('2026-06-11') }, }, }), ); expect(colonyFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: { status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] }, }, }), ); const [strings, ...values] = queryRaw.mock.calls[0] as [ readonly string[], ...unknown[], ]; expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL'); // One day of margin on both sides: the SQL end day is a plain UTC date, // the event day is the Berlin day of the withdrawal-window helper. expect(values).toEqual(['2026-06-02', '2026-06-11']); }); it('bounds the withdrawal query by the Berlin day of the run, not by the instant it is given', async () => { const { db, queryRaw } = fakeDb({}); const rule = DAILY_EVALUATION_RULES.find( (r) => r.type === 'WithdrawalEnded', )!; // 2026-06-10T23:30Z is already 2026-06-11 in Berlin. await rule.occurrences(db, new Date('2026-06-10T23:30:00.000Z'), { seasons, day: '2026-06-11', }); const [, ...values] = queryRaw.mock.calls[0] as [ readonly string[], ...unknown[], ]; expect(values).toEqual(['2026-06-03', '2026-06-12']); }); it('takes the end day of a treatment stored with a time part from the Berlin day', async () => { // 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin: one withdrawal day // later the period ends on 2026-06-03, the first day of the window. The // plain UTC date of the SQL would say 2026-06-02. const { db } = fakeDb({ treatments: [ { id: 'treatment-1', colonyId: 'colony-1', date: new Date('2026-06-01T22:30:00.000Z'), endDate: null, withdrawalPeriodDays: 1, }, ], }); const events = await eventsOn(db, '2026-06-10'); expect(events).toEqual([ expect.objectContaining({ type: 'WithdrawalEnded', payload: { colonyId: 'colony-1', endedOn: '2026-06-03' }, dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-03', }), ]); }); });