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 { NextStepsService } from '../src/autopilot/next-steps.service'; import { berlinToday } from '../src/common/dates'; 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[] = []; const seasonApiaryIds: string[] = []; const winterCheckIds: 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), ...seasonApiaryIds, ...winterCheckIds, ], }, }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.winterCheck.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.feeding.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.deleteMany({ where: { id: { in: [apiaryId, ...seasonApiaryIds] } }, }); 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([]); }); /** The stored season of the e2e apiary in 2001: KIRSCHE by report. */ async function reportKirsche(forApiaryId = apiaryId): Promise { await prisma.apiaryPhenology.create({ data: { apiaryId: forApiaryId, year: 2001, effectivePhase: 'KIRSCHE', reportedPhase: 'KIRSCHE', phaseSource: 'REPORT', segment: 'SCHWARMZEIT', segmentSince: d('2001-04-20'), }, }); } const dropSeason = () => prisma.apiaryPhenology.deleteMany({ where: { apiaryId, year: 2001 } }); it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', async () => { await reportKirsche(); try { const colonyId = await colonyWithLateInspection('report'); await sync(colonyId); const view = await app .get(NextStepsService) .forColony(colonyId, userId, TODAY); expect(view).toMatchObject({ segment: 'SCHWARMZEIT', phase: 'KIRSCHE', phaseSource: 'REPORT', }); expect(view.steps[0]).toMatchObject({ intent: 'INSPECT', urgency: 'OVERDUE', dueOn: '2001-05-10', }); } finally { await dropSeason(); } }); it('A7: KIRSCHE and 9 occupied frames -> a HONEY_SUPER suggestion; 7 frames -> none', async () => { await reportKirsche(); try { const strong = await colonyWithLateInspection('strong'); const weak = await colonyWithLateInspection('weakframes'); await prisma.inspection.updateMany({ where: { colonyId: strong }, data: { colonyStrength: 9 }, }); await prisma.inspection.updateMany({ where: { colonyId: weak }, data: { colonyStrength: 7 }, }); await sync(strong); await sync(weak); const [superSuggestion] = await suggestionsOf(strong, 'HONEY_SUPER'); expect(superSuggestion).toMatchObject({ status: 'OPEN', mode: 'DO', reasonCode: 'HONEY_SUPER_CHECK', requiredPermission: 'inspections:read', dedupeKey: `SEASON_AUTOPILOT:${strong}:2001`, }); expect(await suggestionsOf(weak, 'HONEY_SUPER')).toHaveLength(0); } finally { await dropSeason(); } }); it('never more than 3 steps per colony', async () => { await reportKirsche(); try { const colonyId = await colonyWithLateInspection('fourway'); await prisma.inspection.updateMany({ where: { colonyId }, data: { colonyStrength: 9, feedStatus: 'WEAK' }, }); await prisma.varroaMeasurement.updateMany({ where: { colonyId }, data: { date: d('2001-04-01') }, }); await sync(colonyId); const open = await prisma.suggestion.findMany({ where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' }, select: { intent: true }, }); expect(open.map((s) => s.intent).sort()).toEqual([ 'FEED', 'HONEY_SUPER', 'INSPECT', 'VARROA_MEASURE', ]); const view = await app .get(NextStepsService) .forColony(colonyId, userId, TODAY); expect(view.steps).toHaveLength(3); } finally { await dropSeason(); } }); it('a SeasonPhaseChanged syncs every living colony of the apiary', async () => { const year = Number(berlinToday().slice(0, 4)); const own = await prisma.apiary.create({ data: { name: `Autopilot e2e season ${suffix}` }, }); const other = await prisma.apiary.create({ data: { name: `Autopilot e2e other ${suffix}` }, }); seasonApiaryIds.push(own.id, other.id); await prisma.apiaryPhenology.create({ data: { apiaryId: own.id, year, effectivePhase: 'HERBST', phaseSource: 'REPORT', segment: 'HERBST', segmentSince: d(`${year}-01-01`), }, }); const colonyIn = async (name: string, apiary: string, status = 'ACTIVE') => ( await prisma.colony.create({ data: { code: `AP-E2E-${suffix}-${name}`, apiaryId: apiary, colonyType: 'PRODUCTION_COLONY', status: status as 'ACTIVE', }, }) ).id; const first = await colonyIn('season-1', own.id); const second = await colonyIn('season-2', own.id); const ended = await colonyIn('season-ended', own.id, 'DECEASED'); const elsewhere = await colonyIn('season-other', other.id); colonyIds.push(first, second, ended, elsewhere); await prisma.$transaction((tx) => app.get(DomainEventsService).record(tx, { type: 'SeasonPhaseChanged', aggregateType: 'Apiary', aggregateId: own.id, payload: { apiaryId: own.id, phase: 'HERBST', segment: 'HERBST', previousPhase: 'TRACHTENDE', previousSegment: 'SPAETSOMMER', source: 'REPORT', }, }), ); await drainDomainEvents(app, [own.id]); for (const colonyId of [first, second]) { const [winter] = await suggestionsOf(colonyId, 'WINTER_CHECK'); expect(winter).toMatchObject({ status: 'OPEN', reasonCode: 'WINTER_CHECK_AUTUMN_MISSING', dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}-AUTUMN`, }); } expect(await suggestionsOf(ended, 'WINTER_CHECK')).toHaveLength(0); expect(await suggestionsOf(elsewhere, 'WINTER_CHECK')).toHaveLength(0); for (const colonyId of [first, second]) { const [feed] = await suggestionsOf(colonyId, 'FEED'); expect(feed).toMatchObject({ status: 'OPEN', ruleKey: 'SEASON_AUTOPILOT', reasonCode: 'AUTUMN_FEED_MISSING', dedupeKey: `SEASON_AUTOPILOT:${colonyId}:${year}`, }); } // The wintering-in entry resolves A9 right after it is recorded. const winterCheck = await prisma.winterCheck.create({ data: { colonyId: first, userId, winterYear: year, season: 'AUTUMN', date: new Date(), }, }); winterCheckIds.push(winterCheck.id); await prisma.$transaction((tx) => app.get(DomainEventsService).record(tx, { type: 'WinterCheckRecorded', aggregateType: 'WinterCheck', aggregateId: winterCheck.id, payload: { colonyId: first }, }), ); await drainDomainEvents(app, [winterCheck.id]); const [resolved] = await suggestionsOf(first, 'WINTER_CHECK'); expect(resolved.status).toBe('RESOLVED'); const [stillOpen] = await suggestionsOf(second, 'WINTER_CHECK'); expect(stillOpen.status).toBe('OPEN'); }); it('an apiary-wide sync and single-colony syncs running together finish without a deadlock', async () => { const year = Number(berlinToday().slice(0, 4)); const crowded = await prisma.apiary.create({ data: { name: `Autopilot e2e crowd ${suffix}` }, }); seasonApiaryIds.push(crowded.id); await prisma.apiaryPhenology.create({ data: { apiaryId: crowded.id, year, effectivePhase: 'HERBST', phaseSource: 'REPORT', segment: 'HERBST', segmentSince: d(`${year}-01-01`), }, }); const ids: string[] = []; for (let n = 0; n < 5; n += 1) { const colony = await prisma.colony.create({ data: { code: `AP-E2E-${suffix}-crowd-${n}`, apiaryId: crowded.id, colonyType: 'PRODUCTION_COLONY', }, }); ids.push(colony.id); colonyIds.push(colony.id); } const today = berlinToday(); await Promise.all([ prisma.$transaction((tx) => autopilot.syncApiaryIn(tx, crowded.id, today), ), ...[...ids] .reverse() .map((id) => prisma.$transaction((tx) => autopilot.syncColonyIn(tx, id, today)), ), prisma.$transaction((tx) => autopilot.syncApiaryIn(tx, crowded.id, today), ), ]); for (const id of ids) { expect(await suggestionsOf(id, 'WINTER_CHECK')).toHaveLength(1); expect(await suggestionsOf(id, 'FEED')).toHaveLength(1); } }); it('HERBST approximation: feedings count from the calendar day of TRACHTENDE (08-01), not from the real start of SPAETSOMMER', async () => { await prisma.apiaryPhenology.create({ data: { apiaryId, year: 2001, effectivePhase: 'HERBST', phaseSource: 'REPORT', segment: 'HERBST', segmentSince: d('2001-09-15'), }, }); try { const feedAt = async (name: string, day: string) => { const colonyId = await colonyWithLateInspection(name); await prisma.feeding.create({ data: { colonyId, userId, date: d(day), feedType: 'SUGAR_WATER', amount: 5, unit: 'L', }, }); await sync(colonyId, '2001-09-20'); return colonyId; }; const before = await feedAt('fed-before', '2001-07-30'); const after = await feedAt('fed-after', '2001-08-05'); expect(await suggestionsOf(before, 'FEED')).toHaveLength(1); expect(await suggestionsOf(after, 'FEED')).toHaveLength(0); } finally { await dropSeason(); } }); it('A5 open and an A6 candidate present -> still one FEED suggestion', async () => { await prisma.apiaryPhenology.create({ data: { apiaryId, year: 2001, effectivePhase: 'HERBST', phaseSource: 'REPORT', segment: 'HERBST', segmentSince: d('2001-09-15'), }, }); try { const colonyId = await colonyWithLateInspection('feedboth'); await prisma.inspection.updateMany({ where: { colonyId }, data: { feedStatus: 'WEAK' }, }); await sync(colonyId, '2001-09-20'); await sync(colonyId, '2001-09-21'); const open = await prisma.suggestion.findMany({ where: { colonyId, intent: 'FEED', status: 'OPEN' }, }); expect(open).toHaveLength(1); } finally { await dropSeason(); } }); });