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,252 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
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`);
|
||||
|
||||
const NOTHING_NEW = {
|
||||
WithdrawalEnded: 0,
|
||||
TaskDue: 0,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* The job evaluates the whole database. The e2e files share it and run in
|
||||
* parallel, so the evaluation runs on days in 2001: no other file has
|
||||
* records there, and the events of this file stay its own. Called through
|
||||
* evaluate(day) instead of runCli, which would also write JobRun rows that
|
||||
* test/jobs.e2e-spec.ts counts.
|
||||
*/
|
||||
describe('daily-evaluation (e2e)', () => {
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let job: DailyEvaluationJob;
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
const eventsOf = (aggregateId: string) =>
|
||||
prisma.domainEvent.findMany({
|
||||
where: { aggregateId },
|
||||
select: {
|
||||
type: true,
|
||||
aggregateType: true,
|
||||
payload: true,
|
||||
dedupeKey: true,
|
||||
actorUserId: true,
|
||||
},
|
||||
orderBy: { type: 'asc' },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [JobsModule],
|
||||
}).compile();
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
job = app.get(DailyEvaluationJob);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `daily-evaluation-e2e-${suffix}@example.com`,
|
||||
passwordHash: 'not-used',
|
||||
name: 'Daily Evaluation E2E',
|
||||
role: { connect: { key: 'STAFF' } },
|
||||
},
|
||||
});
|
||||
ids.user = user.id;
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Daily Evaluation ${suffix}` },
|
||||
});
|
||||
ids.apiary = apiary.id;
|
||||
const queen = async (
|
||||
name: string,
|
||||
data: Pick<Prisma.QueenUncheckedCreateInput, 'hatchYear' | 'status'>,
|
||||
) => {
|
||||
const created = await prisma.queen.create({
|
||||
data: { code: `DE-E2E-${suffix}-${name}`, ...data },
|
||||
});
|
||||
ids[`queen:${name}`] = created.id;
|
||||
};
|
||||
await queen('aged', { hatchYear: 1999, status: 'ACTIVE' });
|
||||
await queen('young', { hatchYear: 2000, status: 'ACTIVE' });
|
||||
await queen('reserve', { hatchYear: 1999, status: 'RESERVE' });
|
||||
|
||||
// Living, created 2001-04-20 and never measured: varroa due 2001-05-18.
|
||||
const living = await prisma.colony.create({
|
||||
data: {
|
||||
code: `DE-E2E-${suffix}-living`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
currentQueenId: ids['queen:aged'],
|
||||
createdAt: d('2001-04-20'),
|
||||
},
|
||||
});
|
||||
ids.living = living.id;
|
||||
const dead = await prisma.colony.create({
|
||||
data: {
|
||||
code: `DE-E2E-${suffix}-dead`,
|
||||
apiaryId: apiary.id,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: 'DECEASED',
|
||||
createdAt: d('2001-04-20'),
|
||||
},
|
||||
});
|
||||
ids.dead = dead.id;
|
||||
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
|
||||
for (const colonyId of [living.id, dead.id]) {
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId, userId: user.id, date: d('2001-04-28') },
|
||||
});
|
||||
}
|
||||
|
||||
const treatment = async (
|
||||
name: string,
|
||||
data: Pick<
|
||||
Prisma.TreatmentUncheckedCreateInput,
|
||||
'date' | 'endDate' | 'withdrawalPeriodDays'
|
||||
>,
|
||||
) => {
|
||||
const created = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId: living.id,
|
||||
userId: user.id,
|
||||
product: 'Oxalsäure',
|
||||
...data,
|
||||
},
|
||||
});
|
||||
ids[`treatment:${name}`] = created.id;
|
||||
};
|
||||
// 2001-05-10 + 7 = 2001-05-17, in the window of 2001-05-20.
|
||||
await treatment('ending', {
|
||||
date: d('2001-05-08'),
|
||||
endDate: d('2001-05-10'),
|
||||
withdrawalPeriodDays: 7,
|
||||
});
|
||||
// 2001-05-01 + 7 = 2001-05-08, before the window.
|
||||
await treatment('ended-earlier', {
|
||||
date: d('2001-05-01'),
|
||||
withdrawalPeriodDays: 7,
|
||||
});
|
||||
await treatment('no-withdrawal', { date: d('2001-05-15') });
|
||||
|
||||
const task = async (
|
||||
name: string,
|
||||
data: Pick<Prisma.TaskUncheckedCreateInput, 'dueDate' | 'status'>,
|
||||
) => {
|
||||
const created = await prisma.task.create({
|
||||
data: { title: `Daily evaluation ${suffix} ${name}`, ...data },
|
||||
});
|
||||
ids[`task:${name}`] = created.id;
|
||||
};
|
||||
await task('open', { dueDate: d('2001-05-18'), status: 'OPEN' });
|
||||
await task('done', { dueDate: d('2001-05-18'), status: 'DONE' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const aggregateIds = Object.entries(ids)
|
||||
.filter(([key]) => !['user', 'apiary'].includes(key))
|
||||
.map(([, id]) => id);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: aggregateIds } },
|
||||
});
|
||||
const colonyIds = [ids.living, ids.dead].filter(Boolean);
|
||||
await prisma.treatment.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.task.deleteMany({
|
||||
where: { title: { startsWith: `Daily evaluation ${suffix}` } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.queen.deleteMany({
|
||||
where: { code: { startsWith: `DE-E2E-${suffix}` } },
|
||||
});
|
||||
if (ids.apiary) {
|
||||
await prisma.apiary.deleteMany({ where: { id: ids.apiary } });
|
||||
}
|
||||
if (ids.user) {
|
||||
await prisma.user.deleteMany({ where: { id: ids.user } });
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
||||
const first = await job.evaluate(at('2001-05-20'));
|
||||
const second = await job.evaluate(at('2001-05-20'));
|
||||
// The next day the occurrence is still in the window - and still known.
|
||||
const nextDay = await job.evaluate(at('2001-05-21'));
|
||||
|
||||
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
||||
expect(second).toEqual(NOTHING_NEW);
|
||||
expect(nextDay).toEqual(NOTHING_NEW);
|
||||
expect(await eventsOf(ids['treatment:ending'])).toEqual([
|
||||
{
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
payload: { colonyId: ids.living, endedOn: '2001-05-17' },
|
||||
dedupeKey: `WithdrawalEnded:${ids['treatment:ending']}:2001-05-17`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids['treatment:ended-earlier'])).toEqual([]);
|
||||
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
|
||||
await job.evaluate(at('2001-05-20'));
|
||||
|
||||
expect(await eventsOf(ids['task:open'])).toEqual([
|
||||
{
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
payload: { dueOn: '2001-05-18' },
|
||||
dedupeKey: `TaskDue:${ids['task:open']}:2001-05-18`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids['task:done'])).toEqual([]);
|
||||
expect(await eventsOf(ids.living)).toEqual([
|
||||
{
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
|
||||
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
|
||||
actorUserId: null,
|
||||
},
|
||||
{
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
|
||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports active queens from their second year in the first days of January', async () => {
|
||||
await job.evaluate(at('2001-01-05'));
|
||||
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
|
||||
|
||||
expect(await eventsOf(ids['queen:aged'])).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
payload: { hatchYear: 1999, colonyId: ids.living },
|
||||
dedupeKey: `QueenAged:${ids['queen:aged']}:2001-01-01`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids['queen:young'])).toEqual([]);
|
||||
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user