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
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user