feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,146 @@
|
||||
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 { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
|
||||
type RawCall = [readonly string[], ...unknown[]];
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
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[]]
|
||||
>;
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(() => 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('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
||||
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',
|
||||
}),
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a second run on the same day records nothing new', async () => {
|
||||
await job.run();
|
||||
|
||||
expect(await job.run()).toEqual({
|
||||
WithdrawalEnded: 0,
|
||||
TaskDue: 0,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
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