Files
ApiCore/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 02eb16924e feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:22:36 +02:00

467 lines
16 KiB
TypeScript

import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
const d = (day: string): Date => new Date(`${day}T00:00: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
* TimeEventsStep.run(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 step: TimeEventsStep;
let autopilotStep: AutopilotStep;
let expiryStep: SuggestionsExpiryStep;
const ids: Record<string, string> = {};
/** Colonies of the autopilot tests; the steps only see these. */
const autopilotColonyIds: 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);
step = app.get(TimeEventsStep);
expiryStep = app.get(SuggestionsExpiryStep);
// The real AutopilotStep, but on the colonies of this file only: other
// files share the database and must not be reconciled on a 2001 day.
autopilotStep = new AutopilotStep(
{
colony: {
findMany: (args: Prisma.ColonyFindManyArgs) =>
prisma.colony.findMany({
...args,
where: {
AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }],
},
}),
},
automationRule: prisma.automationRule,
$transaction: prisma.$transaction.bind(prisma) as unknown,
} as unknown as PrismaService,
app.get(AutopilotService),
);
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, in the swarm season (every 7 days): the next
// inspection is due 2001-05-05 and overdue on 2001-05-06.
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, ...autopilotColonyIds].filter(
Boolean,
);
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
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 step.run('2001-05-20');
const second = await step.run('2001-05-20');
// The next day the occurrence is still in the window - and still known.
const nextDay = await step.run('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 varroa checks only for open tasks and living colonies', async () => {
await step.run('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: 'VarroaMeasurementDue',
aggregateType: 'Colony',
payload: {
dueOn: '2001-05-18',
lastMeasuredOn: null,
kind: 'INTERVAL',
anchor: 'none',
},
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('InspectionOverdue follows the segment interval', async () => {
await step.run('2001-05-12');
// SCHWARMZEIT since 2001-04-15: 2001-04-28 + 7 = 2001-05-05, not + 21.
expect(
(await eventsOf(ids.living)).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2001-05-05',
lastInspectedOn: '2001-04-28',
firstOfYear: false,
},
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-06`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
await step.run('2002-03-10');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([]);
// FRUEHJAHR begins 2002-03-20; the colony was last inspected in 2001.
await step.run('2002-03-21');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2002-03-20',
lastInspectedOn: '2001-04-28',
firstOfYear: true,
},
dedupeKey: `InspectionOverdue:${ids.living}:2002-03-21`,
actorUserId: null,
},
]);
});
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
await step.run('2001-09-09');
await step.run('2001-09-10');
// One per queen and year: the 2002 run of the test above added its own.
const events = await eventsOf(ids['queen:aged']);
expect(events.map((event) => event.dedupeKey)).toEqual([
`QueenAged:${ids['queen:aged']}:2001-01-01`,
`QueenAged:${ids['queen:aged']}:2002-01-01`,
]);
expect(events.slice(0, 1)).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([]);
});
describe('autopilot and suggestions-expiry steps', () => {
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
async function lateColony(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `DE-E2E-${suffix}-ap-${name}`,
apiaryId: ids.apiary,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
autopilotColonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId: ids.user,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const inspectSuggestions = (colonyId: string) =>
prisma.suggestion.findMany({
where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' },
orderBy: { createdAt: 'asc' },
});
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
const colonyId = await lateColony('twice');
const first = await autopilotStep.run('2001-05-12');
const firstExpiry = await expiryStep.run('2001-05-12');
const second = await autopilotStep.run('2001-05-12');
const secondExpiry = await expiryStep.run('2001-05-12');
expect(first).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 1,
suggestionsResolved: 0,
});
expect(second).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 0,
suggestionsResolved: 0,
});
expect(firstExpiry).toEqual({ suggestionsExpired: 0 });
expect(secondExpiry).toEqual({ suggestionsExpired: 0 });
expect(await inspectSuggestions(colonyId)).toHaveLength(1);
});
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
const colonyId = await lateColony('expires');
await autopilotStep.run('2001-05-12');
// expiresAt = 2001-06-11
expect(await expiryStep.run('2001-06-11')).toEqual({
suggestionsExpired: 0,
});
expect(await expiryStep.run('2001-06-12')).toEqual({
suggestionsExpired: expect.any(Number) as number,
});
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null });
});
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
const colonyId = await lateColony('again');
await autopilotStep.run('2001-05-12');
await expiryStep.run('2001-06-12');
const result = await autopilotStep.run('2001-06-12');
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['EXPIRED', 'OPEN'],
);
});
it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => {
const colonyId = await lateColony('overdue20');
await prisma.inspection.updateMany({
where: { colonyId },
data: { date: d('2001-04-10') },
});
await step.run('2001-05-12');
await autopilotStep.run('2001-05-12');
// Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up.
expect(
await prisma.domainEvent.count({
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
}),
).toBe(0);
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17');
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await lateColony('returns');
await autopilotStep.run('2001-05-12');
const fresh = await prisma.inspection.create({
data: { colonyId, userId: ids.user, date: d('2001-05-12') },
});
const resolved = await autopilotStep.run('2001-05-12');
await prisma.inspection.delete({ where: { id: fresh.id } });
const returned = await autopilotStep.run('2001-05-12');
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['RESOLVED', 'OPEN'],
);
});
});
});