Files
ApiCore/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts
T
Nick SzaunigandClaude Opus 5.5 b912d29166
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s
feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- Authority notice PDFs: pure model plus renderer, TSK count in A4
  landscape, FEHLT for missing particulars, stored totals with correction
  note; GET /authority-notices/:id/pdf with compliance:read.
- Compliance notifications in category COMPLIANCE: herd change and TSK
  reference day, at most one unread per topic and user (advisory lock);
  daily rule TskReferenceDayReached with Berlin day and catch-up; handlers
  in API and worker.
- Readiness core: live evaluation of seven pure rules, fingerprinted
  acknowledgements that reopen when revoked or stale, subject masking,
  read-only GET paths; READINESS_RULES injectable; kept out of the worker.
- e2e hygiene: no unscoped deletes, seeded settings rows saved/restored.
- ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:48:57 +02:00

771 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, object>;
}
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<string, unknown> }) =>
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<DailyEvent[]> {
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<string, DailyEvent>(),
): Promise<Map<string, DailyEvent>> {
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<string, DailyEvent>, 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<string, unknown> }) =>
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',
}),
]);
});
});