Files
ApiCore/ApiCore-Backend/test/autopilot.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

410 lines
14 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** Fixed day inside the swarm season by calendar; 2001 keeps it apart from other files. */
const TODAY = '2001-05-12';
/**
* The autopilot sync against a real PostgreSQL, on fixed days in 2001. The
* colonies are created 2001-04-20, so no other file's data is reached.
*/
describe('Autopilot (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let autopilot: AutopilotService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let userId: string;
const colonyIds: string[] = [];
const roleIds: string[] = [];
const userIds: string[] = [];
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
async function colonyWithLateInspection(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-${name}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const sync = (colonyId: string, day = TODAY) =>
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, colonyId, day));
const suggestionsOf = (colonyId: string, intent = 'INSPECT') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', intent: intent as 'INSPECT' },
orderBy: { createdAt: 'asc' },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
autopilot = app.get(AutopilotService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Autopilot e2e ${suffix}` } })
).id;
userId = (
await prisma.user.create({
data: {
email: `autopilot-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Autopilot e2e',
role: { connect: { key: 'STAFF' } },
},
})
).id;
});
afterAll(async () => {
const inspections = await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspections.map((i) => i.id) } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', async () => {
const colonyId = await colonyWithLateInspection('overdue');
const inspection = await prisma.inspection.findFirstOrThrow({
where: { colonyId },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'OPEN',
mode: 'DO',
priority: 'HIGH',
ruleKey: 'INSPECTION_INTERVAL',
reasonCode: 'INSPECTION_DUE',
requiredPermission: 'inspections:read',
relatedType: 'COLONY',
relatedId: colonyId,
dedupeKey: `INSPECTION_INTERVAL:${colonyId}:${inspection.id}`,
});
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-10');
expect(suggestion.expiresAt?.toISOString().slice(0, 10)).toBe('2001-06-11');
});
it('a second sync refreshes instead of creating another suggestion', async () => {
const colonyId = await colonyWithLateInspection('refresh');
await sync(colonyId);
const again = await sync(colonyId, '2001-05-13');
expect(again).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('after a new inspection the INSPECT suggestion is RESOLVED', async () => {
const colonyId = await colonyWithLateInspection('resolved');
await sync(colonyId);
await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 1 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'RESOLVED',
decisionNote: 'Bedingung entfällt',
dedupeKey: null,
});
});
it('two colony syncs in parallel leave one INSPECT suggestion', async () => {
const colonyId = await colonyWithLateInspection('parallel');
await Promise.all([sync(colonyId), sync(colonyId), sync(colonyId)]);
const open = (await suggestionsOf(colonyId)).filter(
(suggestion) => suggestion.status === 'OPEN',
);
expect(open).toHaveLength(1);
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await colonyWithLateInspection('returns');
await sync(colonyId);
const fresh = await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
await sync(colonyId);
await prisma.inspection.delete({ where: { id: fresh.id } });
const result = await sync(colonyId);
expect(result.opened).toBe(1);
expect(
(await suggestionsOf(colonyId)).map((suggestion) => suggestion.status),
).toEqual(['RESOLVED', 'OPEN']);
});
it('a dismissed suggestion keeps its anchor: the same condition is not proposed again', async () => {
const colonyId = await colonyWithLateInspection('dismissed');
await sync(colonyId);
await prisma.suggestion.updateMany({
where: { colonyId, intent: 'INSPECT' },
data: { status: 'DISMISSED' },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('an ended colony gets nothing', async () => {
const colonyId = await colonyWithLateInspection('ended');
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DECEASED' },
});
expect(await sync(colonyId)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(await suggestionsOf(colonyId)).toHaveLength(0);
});
it('proposes VARROA_MEASURE (A2) when the last measurement is older than 28 days', async () => {
const colonyId = await colonyWithLateInspection('varroa');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-04-07') },
});
await sync(colonyId);
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
ruleKey: 'SEASON_AUTOPILOT',
reasonCode: 'VARROA_MEASURE_DUE',
requiredPermission: 'varroa:read',
});
expect(measure.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-05');
});
it('autopilot.colony-sync resolves a stale autopilot suggestion when an inspection event is delivered', async () => {
// Real today: a colony inspected and measured today has no candidate.
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-event`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const inspection = await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: new Date() },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: new Date(),
method: 'NATURAL_MITE_FALL',
},
});
const stale = await prisma.suggestion.create({
data: {
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colony.id,
colonyId: colony.id,
reasonCode: 'INSPECTION_DUE',
evidence: {},
dedupeKey: `INSPECTION_INTERVAL:${colony.id}:stale`,
},
});
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId: colony.id },
}),
);
await drainDomainEvents(app, [inspection.id]);
const after = await prisma.suggestion.findUniqueOrThrow({
where: { id: stale.id },
});
expect(after.status).toBe('RESOLVED');
expect(after.decisionNote).toBe('Bedingung entfällt');
});
/** A colony with a treatment that ended 2001-04-28 and an older measurement. */
async function colonyAfterTreatment(
name: string,
measurements: string[],
): Promise<string> {
const colonyId = await colonyWithLateInspection(name);
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
for (const date of measurements) {
await prisma.varroaMeasurement.create({
data: { colonyId, userId, date: d(date), method: 'NATURAL_MITE_FALL' },
});
}
await prisma.treatment.create({
data: {
colonyId,
userId,
date: d('2001-04-14'),
endDate: d('2001-04-28'),
product: 'Oxuvar',
},
});
return colonyId;
}
it('A3: treatment ended 14 days ago -> VARROA_RECHECK for varroa readers, and no A2 beside it', async () => {
const colonyId = await colonyAfterTreatment('recheck', ['2001-04-01']);
await sync(colonyId);
const [recheck] = await suggestionsOf(colonyId, 'VARROA_RECHECK');
expect(recheck).toMatchObject({
status: 'OPEN',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
requiredPermission: 'varroa:read',
});
expect(recheck.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-12');
expect(await suggestionsOf(colonyId, 'VARROA_MEASURE')).toHaveLength(0);
});
it('A3: a measurement on the end day does not count, one the day after does', async () => {
const onEndDay = await colonyAfterTreatment('endday', ['2001-04-28']);
const dayAfter = await colonyAfterTreatment('dayafter', ['2001-04-29']);
await sync(onEndDay);
await sync(dayAfter);
expect(await suggestionsOf(onEndDay, 'VARROA_RECHECK')).toHaveLength(1);
expect(await suggestionsOf(dayAfter, 'VARROA_RECHECK')).toHaveLength(0);
});
it('A2 winter window: no measurement since 11-01 -> VARROA_WINTER_MEASURE with anchor winter-2001', async () => {
const colonyId = await colonyWithLateInspection('winter');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-09-01') },
});
await sync(colonyId, '2001-11-20');
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
reasonCode: 'VARROA_WINTER_MEASURE',
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:winter-2001`,
});
expect(await suggestionsOf(colonyId, 'INSPECT')).toHaveLength(0);
});
it('a user without varroa:read sees neither the A2 nor the A3 suggestion', async () => {
const password = 'SuperSecret123!';
const tokenFor = async (label: string, keys: string[]) => {
const role = await prisma.role.create({
data: {
key: `AP_E2E_${label.toUpperCase()}_${suffix}`,
name: `Autopilot e2e ${label}`,
permissions: {
create: keys.map((key) => ({ permission: { connect: { key } } })),
},
},
});
roleIds.push(role.id);
const email = `autopilot-e2e-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Autopilot e2e ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
};
const full = await tokenFor('full', ['tasks:read', 'varroa:read']);
const noVarroa = await tokenFor('novarroa', ['tasks:read']);
const colonyId = await colonyAfterTreatment('visible', ['2001-04-01']);
await sync(colonyId);
const list = (token: string) =>
request(app.getHttpServer())
.get(`/api/v1/suggestions?colonyId=${colonyId}`)
.set('Authorization', `Bearer ${token}`);
const seen = await list(full);
const hidden = await list(noVarroa);
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
expect(hidden.body).toEqual([]);
});
});