- 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]>
771 lines
26 KiB
TypeScript
771 lines
26 KiB
TypeScript
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',
|
||
}),
|
||
]);
|
||
});
|
||
});
|