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; 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 { 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 { 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([]); }); });