feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
DAILY_EVALUATION_STEP_ORDER,
|
||||
DailyEvaluationStepName,
|
||||
runDailyEvaluationSteps,
|
||||
} from './daily-evaluation-steps';
|
||||
import type { JobResult } from './job';
|
||||
|
||||
function step(
|
||||
name: DailyEvaluationStepName,
|
||||
run: (today: string) => Promise<JobResult>,
|
||||
): { name: DailyEvaluationStepName; run: jest.Mock } {
|
||||
return { name, run: jest.fn(run) };
|
||||
}
|
||||
|
||||
describe('runDailyEvaluationSteps', () => {
|
||||
const logger = new Logger('test');
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('has the steps of the plan in this order', () =>
|
||||
expect(DAILY_EVALUATION_STEP_ORDER).toEqual([
|
||||
'phenology',
|
||||
'time-events',
|
||||
'autopilot',
|
||||
'suggestions-expiry',
|
||||
'digest',
|
||||
]));
|
||||
|
||||
it('runs the steps in the fixed order with the day and merges their counters', async () => {
|
||||
const order: string[] = [];
|
||||
const digest = step('digest', () => {
|
||||
order.push('digest');
|
||||
return Promise.resolve({ digests: 3 });
|
||||
});
|
||||
const timeEvents = step('time-events', () => {
|
||||
order.push('time-events');
|
||||
return Promise.resolve({ TaskDue: 1 });
|
||||
});
|
||||
|
||||
const { result, failed } = await runDailyEvaluationSteps(
|
||||
[digest, timeEvents],
|
||||
'2026-06-10',
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(order).toEqual(['time-events', 'digest']);
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(result).toEqual({ TaskDue: 1, digests: 3 });
|
||||
expect(failed).toEqual([]);
|
||||
});
|
||||
|
||||
it('a failing step leaves the others running and the run FAILED with all counters', async () => {
|
||||
const phenology = step('phenology', () =>
|
||||
Promise.resolve({ phenologyApiaries: 2 }),
|
||||
);
|
||||
const timeEvents = step('time-events', () =>
|
||||
Promise.reject(
|
||||
new JobPartialFailure({ WithdrawalEnded: 1, TaskDue: 0 }, ['TaskDue']),
|
||||
),
|
||||
);
|
||||
const autopilot = step('autopilot', () =>
|
||||
Promise.reject(new Error('connection reset')),
|
||||
);
|
||||
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
|
||||
|
||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||
[phenology, timeEvents, autopilot, digest],
|
||||
'2026-06-10',
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(digest.run).toHaveBeenCalled();
|
||||
expect(failed).toEqual(['time-events', 'autopilot']);
|
||||
expect(failures).toEqual(['time-events: TaskDue', 'autopilot']);
|
||||
expect(result).toEqual({
|
||||
phenologyApiaries: 2,
|
||||
WithdrawalEnded: 1,
|
||||
TaskDue: 0,
|
||||
digests: 4,
|
||||
});
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Step "autopilot" failed: connection reset',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Logger } from '@nestjs/common';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import type { JobResult } from './job';
|
||||
|
||||
/** The steps of the daily evaluation, in the order they run. */
|
||||
export const DAILY_EVALUATION_STEP_ORDER = [
|
||||
'phenology',
|
||||
'time-events',
|
||||
'autopilot',
|
||||
'suggestions-expiry',
|
||||
'digest',
|
||||
] as const;
|
||||
|
||||
export type DailyEvaluationStepName =
|
||||
(typeof DAILY_EVALUATION_STEP_ORDER)[number];
|
||||
|
||||
/**
|
||||
* One step of the daily evaluation. `today` is the Berlin day of the run
|
||||
* (YYYY-MM-DD). A step commits in transactions of its own; it reports its
|
||||
* counters, and fails by throwing - a JobPartialFailure if it still has
|
||||
* counters to report.
|
||||
*/
|
||||
export interface DailyEvaluationStep {
|
||||
readonly name: DailyEvaluationStepName;
|
||||
run(today: string): Promise<JobResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the steps in DAILY_EVALUATION_STEP_ORDER, whatever order they come
|
||||
* in. A failing step is logged and does not stop the others; the counters of
|
||||
* all steps - also the partial ones of a failed step - are merged into
|
||||
* `result`, `failed` names the steps that failed and `failures` says why.
|
||||
*/
|
||||
export async function runDailyEvaluationSteps(
|
||||
steps: readonly DailyEvaluationStep[],
|
||||
today: string,
|
||||
logger: Logger,
|
||||
): Promise<{
|
||||
result: JobResult;
|
||||
failed: DailyEvaluationStepName[];
|
||||
/** One entry per failed step, with the failed parts: "time-events: QueenAged". */
|
||||
failures: string[];
|
||||
}> {
|
||||
const ordered = [...steps].sort(
|
||||
(a, b) =>
|
||||
DAILY_EVALUATION_STEP_ORDER.indexOf(a.name) -
|
||||
DAILY_EVALUATION_STEP_ORDER.indexOf(b.name),
|
||||
);
|
||||
let result: JobResult = {};
|
||||
const failed: DailyEvaluationStepName[] = [];
|
||||
const failures: string[] = [];
|
||||
for (const step of ordered) {
|
||||
try {
|
||||
result = { ...result, ...(await step.run(today)) };
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
logger.error(`Step "${step.name}" failed: ${message}`, stack);
|
||||
failed.push(step.name);
|
||||
if (error instanceof JobPartialFailure) {
|
||||
result = { ...result, ...error.result };
|
||||
failures.push(`${step.name}: ${error.failedSteps.join(', ')}`);
|
||||
} else {
|
||||
failures.push(step.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result, failed, failures };
|
||||
}
|
||||
@@ -1,146 +1,64 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
|
||||
type RawCall = [readonly string[], ...unknown[]];
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
import type { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
describe('DailyEvaluationJob', () => {
|
||||
const tx = { tx: true } as unknown as Prisma.TransactionClient;
|
||||
/** The database clock: a day the JS clock of the test never shows. */
|
||||
const dbNow = new Date('2026-06-10T02:30:00.000Z');
|
||||
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
|
||||
let taskFindMany: jest.Mock;
|
||||
let transaction: jest.Mock;
|
||||
let recordAll: jest.Mock<
|
||||
Promise<number>,
|
||||
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
|
||||
>;
|
||||
const COUNTS = {
|
||||
WithdrawalEnded: 1,
|
||||
TaskDue: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||
let job: DailyEvaluationJob;
|
||||
|
||||
beforeEach(() => {
|
||||
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
|
||||
Promise.resolve(
|
||||
sql.includes('now()')
|
||||
? [{ now: dbNow }]
|
||||
: [
|
||||
{
|
||||
id: 'treatment-1',
|
||||
colonyId: 'colony-1',
|
||||
date: d('2026-05-25'),
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 14,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
taskFindMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
|
||||
transaction = jest.fn(
|
||||
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
|
||||
);
|
||||
// Like createMany(skipDuplicates) on the unique dedupeKey.
|
||||
const stored = new Set<string>();
|
||||
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
|
||||
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
|
||||
fresh.forEach((event) => stored.add(event.dedupeKey!));
|
||||
return Promise.resolve(fresh.length);
|
||||
});
|
||||
const prisma = {
|
||||
$queryRaw: queryRaw,
|
||||
$transaction: transaction,
|
||||
task: { findMany: taskFindMany },
|
||||
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
} as unknown as PrismaService;
|
||||
job = new DailyEvaluationJob(prisma, {
|
||||
recordAll,
|
||||
} as unknown as DomainEventsService);
|
||||
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||
timeEvents.run.mockResolvedValue(COUNTS);
|
||||
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is the daily-evaluation job', () => {
|
||||
expect(job.name).toBe('daily-evaluation');
|
||||
});
|
||||
|
||||
it('evaluates the day of the database clock and reports new events per type', async () => {
|
||||
const result = await job.run();
|
||||
|
||||
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
|
||||
// window of 2026-06-10 - not in the one of the test's own clock.
|
||||
expect(result).toEqual({
|
||||
WithdrawalEnded: 1,
|
||||
TaskDue: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
});
|
||||
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
|
||||
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
||||
expect(await job.run()).toEqual(COUNTS);
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
||||
it('takes the Berlin day, not the UTC day, around midnight', async () => {
|
||||
jest.setSystemTime(new Date('2026-06-09T22:30:00.000Z'));
|
||||
|
||||
await job.run();
|
||||
|
||||
// Types without an event open no transaction.
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(recordAll.mock.calls).toEqual([
|
||||
[
|
||||
tx,
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: 'WithdrawalEnded',
|
||||
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
tx,
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: 'TaskDue',
|
||||
dedupeKey: 'TaskDue:task-1:2026-06-09',
|
||||
}),
|
||||
],
|
||||
],
|
||||
]);
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('a second run on the same day records nothing new', async () => {
|
||||
await job.run();
|
||||
it('a failing step fails the run with the counters of all steps', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
timeEvents.run.mockRejectedValue(
|
||||
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
|
||||
);
|
||||
|
||||
expect(await job.run()).toEqual({
|
||||
WithdrawalEnded: 0,
|
||||
TaskDue: 0,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
const failure = await job.run().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['time-events'],
|
||||
result: { WithdrawalEnded: 1 },
|
||||
message: 'Daily evaluation failed for: time-events: TaskDue',
|
||||
});
|
||||
});
|
||||
|
||||
it('a failing rule does not stop the others, but fails the run', async () => {
|
||||
const logged = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
taskFindMany.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
await expect(job.evaluate(dbNow)).rejects.toThrow(
|
||||
'Daily evaluation failed for: TaskDue',
|
||||
);
|
||||
|
||||
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({ type: 'WithdrawalEnded' }),
|
||||
]);
|
||||
expect(logged).toHaveBeenCalledWith(
|
||||
'Rule "TaskDue" failed: connection reset',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,39 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyRule,
|
||||
} from './daily-evaluation.rules';
|
||||
DailyEvaluationStep,
|
||||
runDailyEvaluationSteps,
|
||||
} from './daily-evaluation-steps';
|
||||
import { Job, JobResult } from './job';
|
||||
import { JobName } from './job-names';
|
||||
import { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
/**
|
||||
* Records the time-based domain events (CronJob 04:30): one rule per type,
|
||||
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
|
||||
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
|
||||
* run is caught up and a repeated one adds nothing. The result counts the
|
||||
* events that were new, per type.
|
||||
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
|
||||
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
|
||||
* own, and a failing step does not stop the others. The result holds the
|
||||
* counters of all steps; if a step failed, the run fails with that result
|
||||
* (JobPartialFailure), so it shows on the System page.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyEvaluationJob implements Job {
|
||||
readonly name: JobName = 'daily-evaluation';
|
||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
constructor(private readonly timeEvents: TimeEventsStep) {}
|
||||
|
||||
/** Evaluates the current day of the database clock. */
|
||||
/** Evaluates the current Berlin day. */
|
||||
async run(): Promise<JobResult> {
|
||||
const [{ now }] = await this.prisma.$queryRaw<
|
||||
{ now: Date }[]
|
||||
>`SELECT now() AS "now"`;
|
||||
return this.evaluate(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every rule for `today`. A failing rule is logged and does not stop
|
||||
* the others - their events are committed - but fails the run, so it
|
||||
* shows on the System page; the next run catches up.
|
||||
*/
|
||||
async evaluate(today: Date): Promise<JobResult> {
|
||||
const counts: [string, number][] = [];
|
||||
const failed: string[] = [];
|
||||
for (const rule of DAILY_EVALUATION_RULES) {
|
||||
try {
|
||||
counts.push([rule.type, await this.evaluateRule(rule, today)]);
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
|
||||
failed.push(rule.type);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
|
||||
}
|
||||
return Object.fromEntries(counts);
|
||||
}
|
||||
|
||||
/** One transaction per type; returns how many events were new. */
|
||||
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
|
||||
const events = catchUpEvents(
|
||||
await rule.occurrences(this.prisma, today),
|
||||
today,
|
||||
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||
steps,
|
||||
berlinToday(),
|
||||
this.logger,
|
||||
);
|
||||
if (events.length === 0) {
|
||||
return 0;
|
||||
if (failed.length > 0) {
|
||||
throw new JobPartialFailure(result, failed, failures);
|
||||
}
|
||||
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import {
|
||||
ACTIVE_SEASON_MONTHS,
|
||||
CATCH_UP_DAYS,
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyEvent,
|
||||
inCatchUpWindow,
|
||||
INSPECTION_INTERVAL_DAYS,
|
||||
latestCycleOccurrence,
|
||||
QUEEN_AGED_MIN_YEARS,
|
||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
||||
withdrawalEndDay,
|
||||
} from './daily-evaluation.rules';
|
||||
|
||||
@@ -17,8 +13,6 @@ 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`);
|
||||
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
|
||||
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
|
||||
|
||||
/** Every day from `from` to `to`, both included. */
|
||||
function daysBetween(from: string, to: string): string[] {
|
||||
@@ -38,23 +32,25 @@ interface FakeRows {
|
||||
withdrawalPeriodDays: number | null;
|
||||
}[];
|
||||
tasks?: { id: string; dueDate: Date | null }[];
|
||||
queens?: {
|
||||
id: string;
|
||||
hatchYear: number | null;
|
||||
colony: { id: string } | 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?: { colonyId: string; date: Date }[];
|
||||
varroa?: { id: string; colonyId: string; date: Date }[];
|
||||
/** The latest inspection per colony. */
|
||||
inspections?: { colonyId: string; date: Date }[];
|
||||
/** 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;
|
||||
queenFindMany: jest.Mock;
|
||||
colonyFindMany: jest.Mock;
|
||||
}
|
||||
|
||||
@@ -63,32 +59,60 @@ interface FakeDb {
|
||||
* day themselves, the database filters only narrow the candidates.
|
||||
*/
|
||||
function fakeDb(rows: FakeRows): FakeDb {
|
||||
const latest = (records: { colonyId: string; date: Date }[] = []) =>
|
||||
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
|
||||
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
|
||||
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
|
||||
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
|
||||
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
|
||||
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 },
|
||||
queen: { findMany: queenFindMany },
|
||||
colony: { findMany: colonyFindMany },
|
||||
varroaMeasurement: {
|
||||
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
|
||||
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
|
||||
},
|
||||
inspection: {
|
||||
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
|
||||
groupBy: jest.fn().mockResolvedValue(
|
||||
(rows.inspections ?? []).map(({ colonyId, date }) => ({
|
||||
colonyId,
|
||||
_max: { date },
|
||||
})),
|
||||
),
|
||||
},
|
||||
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, queenFindMany, colonyFindMany };
|
||||
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, at(day)), at(day)));
|
||||
events.push(
|
||||
...catchUpEvents(
|
||||
await rule.occurrences(db, d(day), { seasons, day }),
|
||||
d(day),
|
||||
),
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -113,13 +137,12 @@ async function runDaily(
|
||||
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 values of the backbone plan', () => {
|
||||
it('keeps the catch-up window of the backbone plan', () => {
|
||||
expect(CATCH_UP_DAYS).toBe(7);
|
||||
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
|
||||
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
|
||||
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
|
||||
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it('runs one rule per time-based event type', () => {
|
||||
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
|
||||
).toBeNull());
|
||||
});
|
||||
|
||||
describe('latestCycleOccurrence', () => {
|
||||
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
|
||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
|
||||
'2026-06-12',
|
||||
);
|
||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
|
||||
'2026-05-22',
|
||||
);
|
||||
});
|
||||
|
||||
it('is null before the first full interval and for a base in the future', () => {
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
|
||||
).toBeNull();
|
||||
expect(
|
||||
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('counts whole UTC days, not hours', () =>
|
||||
expect(
|
||||
latestCycleOccurrence(
|
||||
new Date('2026-05-01T20:00:00.000Z'),
|
||||
21,
|
||||
at('2026-05-22'),
|
||||
),
|
||||
).toBe('2026-05-22'));
|
||||
|
||||
it('refuses an interval that is not a positive whole number of days', () => {
|
||||
expect(() =>
|
||||
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
|
||||
).toThrow(RangeError);
|
||||
expect(() =>
|
||||
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
|
||||
).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inCatchUpWindow', () => {
|
||||
it('spans today and the 7 days before it', () => {
|
||||
const today = at('2026-06-10');
|
||||
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);
|
||||
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
|
||||
});
|
||||
|
||||
it('works across a year boundary', () => {
|
||||
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
|
||||
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
|
||||
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
|
||||
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
|
||||
// Never measured or inspected: they count from its creation.
|
||||
{ id: 'colony-2', createdAt: d('2026-04-12') },
|
||||
],
|
||||
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
|
||||
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',
|
||||
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
|
||||
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 + 21 = 2026-06-10, not yet due.
|
||||
// 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-2',
|
||||
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
|
||||
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
|
||||
aggregateId: 'colony-1',
|
||||
payload: {
|
||||
dueOn: '2026-05-27',
|
||||
lastInspectedOn: '2026-05-20',
|
||||
firstOfYear: false,
|
||||
},
|
||||
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
|
||||
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits no inspection or varroa event outside March–October', async () => {
|
||||
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
|
||||
describe('QueenAged', () => {
|
||||
const queen = (hatchYear: number | null) => ({
|
||||
id: 'queen-1',
|
||||
hatchYear,
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
|
||||
const dueDays = (type: string) =>
|
||||
[...store.values()]
|
||||
.filter((event) => event.type === type)
|
||||
.map((event) => event.dedupeKey.split(':')[2]);
|
||||
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 } },
|
||||
],
|
||||
});
|
||||
|
||||
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
|
||||
expect(dueDays('VarroaMeasurementDue')).toEqual([
|
||||
'2026-03-07',
|
||||
'2026-04-04',
|
||||
'2026-05-02',
|
||||
'2026-05-30',
|
||||
'2026-06-27',
|
||||
'2026-07-25',
|
||||
'2026-08-22',
|
||||
'2026-09-19',
|
||||
'2026-10-17',
|
||||
]);
|
||||
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
|
||||
expect(dueDays('InspectionOverdue')).toEqual([
|
||||
'2026-03-14',
|
||||
'2026-04-04',
|
||||
'2026-04-25',
|
||||
'2026-05-16',
|
||||
'2026-06-06',
|
||||
'2026-06-27',
|
||||
'2026-07-18',
|
||||
'2026-08-08',
|
||||
'2026-08-29',
|
||||
'2026-09-19',
|
||||
'2026-10-10',
|
||||
'2026-10-31',
|
||||
]);
|
||||
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 () => {
|
||||
const { db, colonyFindMany } = fakeDb({});
|
||||
// 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
|
||||
const { db } = fakeDb({
|
||||
queens: [
|
||||
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
|
||||
{ id: 'queen-2027', hatchYear: 2027, colony: null },
|
||||
{ id: 'queen-unknown', hatchYear: null, colony: null },
|
||||
],
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
|
||||
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') }],
|
||||
});
|
||||
|
||||
expect([...store.values()]).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-2026',
|
||||
payload: { hatchYear: 2026, colonyId: 'colony-1' },
|
||||
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
|
||||
},
|
||||
expect.objectContaining({
|
||||
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
|
||||
}) as DailyEvent,
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-2027',
|
||||
payload: { hatchYear: 2027, colonyId: null },
|
||||
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
|
||||
},
|
||||
]);
|
||||
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, queenFindMany, colonyFindMany } =
|
||||
fakeDb({});
|
||||
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
|
||||
|
||||
await eventsOn(db, '2026-06-10');
|
||||
|
||||
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
|
||||
];
|
||||
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
|
||||
expect(values).toEqual(['2026-06-03', '2026-06-10']);
|
||||
// QueenAged only looks on January 1-8.
|
||||
expect(queenFindMany).not.toHaveBeenCalled();
|
||||
|
||||
await eventsOn(db, '2027-01-08');
|
||||
expect(queenFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,20 @@
|
||||
import { TaskStatus, Treatment } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { addDays, berlinDayOf } from '../common/dates';
|
||||
import {
|
||||
AnyNewDomainEvent,
|
||||
dailyDedupeKey,
|
||||
utcDay,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import { isQueenAged } from '../season/queen-age';
|
||||
import type { SeasonService } from '../season/season.service';
|
||||
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||
|
||||
/** How many days back a run still records an occurrence it missed. */
|
||||
export const CATCH_UP_DAYS = 7;
|
||||
/** A queen counts as aged from this many years after her hatch year. */
|
||||
export const QUEEN_AGED_MIN_YEARS = 2;
|
||||
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
|
||||
export const INSPECTION_INTERVAL_DAYS = 21;
|
||||
/** March to October: varroa and inspection reminders only in these months. */
|
||||
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
|
||||
3, 4, 5, 6, 7, 8, 9, 10,
|
||||
];
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
|
||||
@@ -37,18 +35,35 @@ export type DailyEvent<T extends DailyEventType = DailyEventType> =
|
||||
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
|
||||
day: string;
|
||||
event: AnyNewDomainEvent<T>;
|
||||
/**
|
||||
* The day of the dedupe key when it is not `day`: an occurrence that
|
||||
* repeats on every job day but must be recorded once (QueenAged: the
|
||||
* January 1 of the year).
|
||||
*/
|
||||
anchorDay?: string;
|
||||
}
|
||||
|
||||
/** What a rule needs besides the database: the seasons and the run's day. */
|
||||
export interface DailyRuleContext {
|
||||
seasons: SeasonService;
|
||||
/** The Berlin day of the run, YYYY-MM-DD. */
|
||||
day: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the daily evaluation. `occurrences` reads the candidates and
|
||||
* names the day of each; the job keeps those in the catch-up window
|
||||
* names the day of each; the step keeps those in the catch-up window
|
||||
* (catchUpEvents) and records them under their daily dedupe key. A rule
|
||||
* may narrow its query to the window, but must not rely on that: the day
|
||||
* it reports decides.
|
||||
* it reports decides. `today` is dayToDate(ctx.day).
|
||||
*/
|
||||
export interface DailyRule<T extends DailyEventType = DailyEventType> {
|
||||
readonly type: T;
|
||||
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
|
||||
occurrences(
|
||||
db: PrismaService,
|
||||
today: Date,
|
||||
ctx: DailyRuleContext,
|
||||
): Promise<DailyOccurrence<T>[]>;
|
||||
}
|
||||
|
||||
/** Midnight UTC of the UTC day of `date`. */
|
||||
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
|
||||
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
|
||||
* in whole UTC days; null if not even the first interval has passed.
|
||||
*/
|
||||
export function latestCycleOccurrence(
|
||||
base: Date,
|
||||
intervalDays: number,
|
||||
today: Date,
|
||||
): string | null {
|
||||
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
|
||||
throw new RangeError(
|
||||
`Interval must be a positive whole number of days, got ${intervalDays}`,
|
||||
);
|
||||
}
|
||||
const elapsedDays = Math.round(
|
||||
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
|
||||
);
|
||||
const cycles = Math.floor(elapsedDays / intervalDays);
|
||||
if (cycles < 1) {
|
||||
return null;
|
||||
}
|
||||
return utcDay(dayAfter(base, cycles * intervalDays));
|
||||
}
|
||||
|
||||
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
|
||||
export function inCatchUpWindow(day: string, today: Date): boolean {
|
||||
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
|
||||
}
|
||||
|
||||
function inActiveSeason(day: string): boolean {
|
||||
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrences in the catch-up window, as events with their daily dedupe
|
||||
* key: every run within the window repeats the same key, so the unique
|
||||
@@ -117,9 +104,13 @@ export function catchUpEvents(
|
||||
): DailyEvent[] {
|
||||
return occurrences
|
||||
.filter(({ day }) => inCatchUpWindow(day, today))
|
||||
.map(({ day, event }) => ({
|
||||
.map(({ day, anchorDay, event }) => ({
|
||||
...event,
|
||||
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
|
||||
dedupeKey: dailyDedupeKey(
|
||||
event.type,
|
||||
event.aggregateId,
|
||||
anchorDay ?? day,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -197,34 +188,47 @@ const taskDue: DailyRule<'TaskDue'> = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Active queen from her second year after hatching: once a year, on January 1. */
|
||||
/**
|
||||
* The current queen of a living colony once she is aged
|
||||
* (SEASON_AUTOPILOT.queenMaxAgeYears): on the first job day she is, once per
|
||||
* queen and year (the key is anchored on January 1), so a queen assigned
|
||||
* later in the year, or a changed hatch year, is caught too.
|
||||
*/
|
||||
const queenAged: DailyRule<'QueenAged'> = {
|
||||
type: 'QueenAged',
|
||||
async occurrences(db, today) {
|
||||
const year = today.getUTCFullYear();
|
||||
const day = `${year}-01-01`;
|
||||
if (!inCatchUpWindow(day, today)) {
|
||||
return [];
|
||||
}
|
||||
const queens = await db.queen.findMany({
|
||||
async occurrences(db, _today, { day }) {
|
||||
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||
const year = Number(day.slice(0, 4));
|
||||
const colonies = await db.colony.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
|
||||
...LIVING_COLONY_WHERE,
|
||||
currentQueen: {
|
||||
status: 'ACTIVE',
|
||||
hatchYear: { lte: year - config.queenMaxAgeYears },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
currentQueen: { select: { id: true, hatchYear: true } },
|
||||
},
|
||||
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
|
||||
});
|
||||
return queens.flatMap(({ id, hatchYear, colony }) => {
|
||||
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
|
||||
return colonies.flatMap(({ id, currentQueen }) => {
|
||||
if (
|
||||
currentQueen === null ||
|
||||
currentQueen.hatchYear === null ||
|
||||
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
day,
|
||||
anchorDay: `${year}-01-01`,
|
||||
event: {
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: id,
|
||||
payload: { hatchYear, colonyId: colony?.id ?? null },
|
||||
aggregateId: currentQueen.id,
|
||||
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
|
||||
},
|
||||
};
|
||||
|
||||
interface CycleDue {
|
||||
colonyId: string;
|
||||
day: string;
|
||||
/** The UTC day of the last record, null if the colony has none. */
|
||||
lastOn: string | null;
|
||||
interface SeasonalColony {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
/** The Berlin day the colony was created on. */
|
||||
createdOn: string;
|
||||
}
|
||||
|
||||
async function livingColonies(db: PrismaService): Promise<SeasonalColony[]> {
|
||||
const colonies = await db.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true, apiaryId: true, createdAt: true },
|
||||
});
|
||||
return colonies.map(({ id, apiaryId, createdAt }) => ({
|
||||
id,
|
||||
apiaryId,
|
||||
createdOn: berlinDayOf(createdAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Living colonies whose cycle comes due in the active season: counted from
|
||||
* the last record, or from the colony's creation if it has none.
|
||||
* Living colony: varroa measurement due, by the season of its apiary
|
||||
* (varroaMeasurementDue): the interval after the last measurement, or the
|
||||
* winter window. One event per due date, on the due day; the 7-day catch-up
|
||||
* only covers job outages (the A2 suggestion carries the reminder after).
|
||||
*/
|
||||
async function colonyCyclesDue(
|
||||
db: PrismaService,
|
||||
lastRecordOn: ReadonlyMap<string, Date>,
|
||||
intervalDays: number,
|
||||
today: Date,
|
||||
): Promise<CycleDue[]> {
|
||||
const colonies = await db.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
return colonies.flatMap(({ id, createdAt }) => {
|
||||
const last = lastRecordOn.get(id) ?? null;
|
||||
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
|
||||
if (day === null || !inActiveSeason(day)) {
|
||||
return [];
|
||||
}
|
||||
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
|
||||
});
|
||||
}
|
||||
|
||||
function latestByColony(
|
||||
rows: { colonyId: string; _max: { date: Date | null } }[],
|
||||
): ReadonlyMap<string, Date> {
|
||||
return new Map(
|
||||
rows.flatMap(({ colonyId, _max }) =>
|
||||
_max.date === null ? [] : [[colonyId, _max.date] as const],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Living colony: varroa measurement due again in the active season. */
|
||||
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
|
||||
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
|
||||
type: 'VarroaMeasurementDue',
|
||||
async occurrences(db, today) {
|
||||
const latest = await db.varroaMeasurement.groupBy({
|
||||
by: ['colonyId'],
|
||||
async occurrences(db, _today, { seasons, day }) {
|
||||
const colonies = await livingColonies(db);
|
||||
const latest = await db.varroaMeasurement.findMany({
|
||||
where: { colony: LIVING_COLONY_WHERE },
|
||||
_max: { date: true },
|
||||
distinct: ['colonyId'],
|
||||
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true, colonyId: true, date: true },
|
||||
});
|
||||
const due = await colonyCyclesDue(
|
||||
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||
const states = await seasons.statesFor(
|
||||
db,
|
||||
latestByColony(latest),
|
||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
||||
today,
|
||||
);
|
||||
return due.map(({ colonyId, day, lastOn }) => ({
|
||||
colonies.map((colony) => colony.apiaryId),
|
||||
day,
|
||||
event: {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
payload: { dueOn: day, lastMeasuredOn: lastOn },
|
||||
},
|
||||
}));
|
||||
);
|
||||
const lastByColony = new Map(latest.map((row) => [row.colonyId, row]));
|
||||
return colonies.flatMap((colony) => {
|
||||
const season = states.get(colony.apiaryId);
|
||||
const last = lastByColony.get(colony.id);
|
||||
const lastMeasuredOn = last === undefined ? null : utcDay(last.date);
|
||||
const due =
|
||||
season === undefined
|
||||
? null
|
||||
: varroaMeasurementDue(
|
||||
{
|
||||
createdOn: colony.createdOn,
|
||||
lastMeasuredOn,
|
||||
lastMeasurementId: last?.id ?? null,
|
||||
},
|
||||
season,
|
||||
config,
|
||||
day,
|
||||
);
|
||||
if (due === null) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
day: due.dueOn,
|
||||
event: {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
payload: {
|
||||
dueOn: due.dueOn,
|
||||
lastMeasuredOn,
|
||||
kind: due.kind,
|
||||
anchor: due.anchor,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Living colony: inspection overdue in the active season. */
|
||||
/**
|
||||
* Living colony: inspection overdue the day after inspectionDueOn - by the
|
||||
* interval of the season segment of its apiary, never in WINTERRUHE; after
|
||||
* the winter on the day after FRUEHJAHR begins ("first of the year"). One
|
||||
* event per due date; the 7-day catch-up only covers job outages (A1 carries
|
||||
* the reminder after). A changed interval (new segment) yields a new due
|
||||
* date and so a second event with another key.
|
||||
*/
|
||||
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
|
||||
type: 'InspectionOverdue',
|
||||
async occurrences(db, today) {
|
||||
async occurrences(db, _today, { seasons, day }) {
|
||||
const colonies = await livingColonies(db);
|
||||
const latest = await db.inspection.groupBy({
|
||||
by: ['colonyId'],
|
||||
where: { colony: LIVING_COLONY_WHERE },
|
||||
_max: { date: true },
|
||||
});
|
||||
const due = await colonyCyclesDue(
|
||||
db,
|
||||
latestByColony(latest),
|
||||
INSPECTION_INTERVAL_DAYS,
|
||||
today,
|
||||
const lastInspected = new Map(
|
||||
latest.flatMap(({ colonyId, _max }) =>
|
||||
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
|
||||
),
|
||||
);
|
||||
return due.map(({ colonyId, day, lastOn }) => ({
|
||||
const { config } = await readRule(db, 'INSPECTION_INTERVAL');
|
||||
const states = await seasons.statesFor(
|
||||
db,
|
||||
colonies.map((colony) => colony.apiaryId),
|
||||
day,
|
||||
event: {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
payload: { dueOn: day, lastInspectedOn: lastOn },
|
||||
},
|
||||
}));
|
||||
);
|
||||
return colonies.flatMap((colony) => {
|
||||
const season = states.get(colony.apiaryId);
|
||||
const lastInspectedOn = lastInspected.get(colony.id) ?? null;
|
||||
const due =
|
||||
season === undefined
|
||||
? null
|
||||
: inspectionDueOn(
|
||||
{ createdOn: colony.createdOn, lastInspectedOn },
|
||||
season,
|
||||
config,
|
||||
);
|
||||
if (due === null) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
day: addDays(due.dueOn, 1),
|
||||
event: {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
payload: {
|
||||
dueOn: due.dueOn,
|
||||
lastInspectedOn,
|
||||
firstOfYear: due.firstOfYear,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
|
||||
withdrawalEnded,
|
||||
taskDue,
|
||||
queenAged,
|
||||
varroaMeasurementDue,
|
||||
varroaDue,
|
||||
inspectionOverdue,
|
||||
];
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { SystemModule } from '../system/system.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { AppBackupJob } from './app-backup.job';
|
||||
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
import { Job } from './job';
|
||||
import { JobRegistry, JOBS } from './job-registry';
|
||||
import { RekeyJob } from './rekey.job';
|
||||
import { TimeEventsStep } from './steps/time-events.step';
|
||||
import { TaskTemplatesJob } from './task-templates.job';
|
||||
|
||||
/**
|
||||
* PermissionResolverService for the job context (BackupService clears its
|
||||
* cache after a restore, which no job does). Not PermissionsModule: its
|
||||
* PermissionCatalogSyncService would rewrite the permission catalog on
|
||||
* every job start. Global like PermissionsModule, so modules that inject
|
||||
* the resolver load here unchanged.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PermissionResolverService],
|
||||
exports: [PermissionResolverService],
|
||||
})
|
||||
class JobPermissionsModule {}
|
||||
|
||||
const JOB_CLASSES = [
|
||||
DailyEvaluationJob,
|
||||
TaskTemplatesJob,
|
||||
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
|
||||
JobPermissionsModule,
|
||||
BackupModule,
|
||||
SystemModule,
|
||||
SeasonModule,
|
||||
],
|
||||
providers: [
|
||||
...JOB_CLASSES,
|
||||
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
|
||||
useFactory: (...jobs: Job[]): Job[] => jobs,
|
||||
inject: JOB_CLASSES,
|
||||
},
|
||||
TimeEventsStep,
|
||||
JobRegistry,
|
||||
IdempotencyCleanupService,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { berlinToday } from '../../common/dates';
|
||||
import { AnyNewDomainEvent } from '../../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SeasonService } from '../../season/season.service';
|
||||
import { JobPartialFailure } from '../../system/job-run.service';
|
||||
import { TimeEventsStep } from './time-events.step';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
describe('TimeEventsStep', () => {
|
||||
const tx = { tx: true } as unknown as Prisma.TransactionClient;
|
||||
let taskFindMany: jest.Mock;
|
||||
let transaction: jest.Mock;
|
||||
let recordAll: jest.Mock<
|
||||
Promise<number>,
|
||||
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
|
||||
>;
|
||||
let step: TimeEventsStep;
|
||||
|
||||
beforeEach(() => {
|
||||
taskFindMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
|
||||
transaction = jest.fn(
|
||||
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
|
||||
);
|
||||
// Like createMany(skipDuplicates) on the unique dedupeKey.
|
||||
const stored = new Set<string>();
|
||||
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
|
||||
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
|
||||
fresh.forEach((event) => stored.add(event.dedupeKey!));
|
||||
return Promise.resolve(fresh.length);
|
||||
});
|
||||
const prisma = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'treatment-1',
|
||||
colonyId: 'colony-1',
|
||||
date: d('2026-05-25'),
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 14,
|
||||
},
|
||||
]),
|
||||
$transaction: transaction,
|
||||
task: { findMany: taskFindMany },
|
||||
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService;
|
||||
step = new TimeEventsStep(
|
||||
prisma,
|
||||
{ recordAll } as unknown as DomainEventsService,
|
||||
new SeasonService(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('is the time-events step', () => {
|
||||
expect(step.name).toBe('time-events');
|
||||
});
|
||||
|
||||
it('reports the new events per type', async () => {
|
||||
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
|
||||
// window of 2026-06-10.
|
||||
expect(await step.run('2026-06-10')).toEqual({
|
||||
WithdrawalEnded: 1,
|
||||
TaskDue: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
||||
await step.run('2026-06-10');
|
||||
|
||||
// Types without an event open no transaction.
|
||||
expect(transaction).toHaveBeenCalledTimes(2);
|
||||
expect(recordAll.mock.calls).toEqual([
|
||||
[
|
||||
tx,
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: 'WithdrawalEnded',
|
||||
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
|
||||
}),
|
||||
],
|
||||
],
|
||||
[
|
||||
tx,
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: 'TaskDue',
|
||||
dedupeKey: 'TaskDue:task-1:2026-06-09',
|
||||
}),
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a second run on the same day records nothing new', async () => {
|
||||
await step.run('2026-06-10');
|
||||
|
||||
expect(await step.run('2026-06-10')).toEqual({
|
||||
WithdrawalEnded: 0,
|
||||
TaskDue: 0,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['summer', '2026-06-10T02:30:00.000Z', '2026-06-10'],
|
||||
['winter', '2026-12-05T03:30:00.000Z', '2026-12-05'],
|
||||
])(
|
||||
'the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey (%s)',
|
||||
async (_season, instant, day) => {
|
||||
taskFindMany.mockResolvedValue([{ id: 'task-1', dueDate: d(day) }]);
|
||||
|
||||
expect(berlinToday(new Date(instant))).toBe(day);
|
||||
await step.run(berlinToday(new Date(instant)));
|
||||
|
||||
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({ dedupeKey: `TaskDue:task-1:${day}` }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('a failing rule does not stop the others, but fails the step with their counters', async () => {
|
||||
const logged = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
taskFindMany.mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
const failure = await step.run('2026-06-10').then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['TaskDue'],
|
||||
result: {
|
||||
WithdrawalEnded: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
},
|
||||
});
|
||||
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({ type: 'WithdrawalEnded' }),
|
||||
]);
|
||||
expect(logged).toHaveBeenCalledWith(
|
||||
'Rule "TaskDue" failed: connection reset',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a malformed day', async () => {
|
||||
await expect(step.run('2026-02-30')).rejects.toThrow('Invalid day');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { dayToDate } from '../../common/dates';
|
||||
import { loggableError } from '../../common/loggable-error';
|
||||
import { DomainEventsService } from '../../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SeasonService } from '../../season/season.service';
|
||||
import { JobPartialFailure } from '../../system/job-run.service';
|
||||
import {
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyRule,
|
||||
DailyRuleContext,
|
||||
} from '../daily-evaluation.rules';
|
||||
import type { DailyEvaluationStep } from '../daily-evaluation-steps';
|
||||
import type { JobResult } from '../job';
|
||||
|
||||
/**
|
||||
* Step "time-events": records the time-based domain events, one rule per
|
||||
* type, see DAILY_EVALUATION_RULES. An occurrence is recorded on every run
|
||||
* within CATCH_UP_DAYS after its day under the same daily dedupe key, so a
|
||||
* missed run is caught up and a repeated one adds nothing. The result counts
|
||||
* the events that were new, per type.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TimeEventsStep implements DailyEvaluationStep {
|
||||
readonly name = 'time-events';
|
||||
private readonly logger = new Logger(TimeEventsStep.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly seasons: SeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs every rule for `day` (the Berlin day of the run). A failing rule is
|
||||
* logged and does not stop the others - their events are committed - but
|
||||
* fails the step with the counters of the rules that ran; the next run
|
||||
* catches up.
|
||||
*/
|
||||
async run(day: string): Promise<JobResult> {
|
||||
const today = dayToDate(day);
|
||||
const context: DailyRuleContext = { seasons: this.seasons, day };
|
||||
const counts: JobResult = {};
|
||||
const failed: string[] = [];
|
||||
for (const rule of DAILY_EVALUATION_RULES) {
|
||||
try {
|
||||
counts[rule.type] = await this.evaluateRule(rule, today, context);
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
|
||||
failed.push(rule.type);
|
||||
}
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
throw new JobPartialFailure(counts, failed);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** One transaction per type; returns how many events were new. */
|
||||
private async evaluateRule(
|
||||
rule: DailyRule,
|
||||
today: Date,
|
||||
context: DailyRuleContext,
|
||||
): Promise<number> {
|
||||
const events = catchUpEvents(
|
||||
await rule.occurrences(this.prisma, today, context),
|
||||
today,
|
||||
);
|
||||
if (events.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user