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 { PhenologyStep } from '../src/phenology/phenology.step'; import { TimeEventsStep } from '../src/jobs/steps/time-events.step'; import { PrismaService } from '../src/prisma/prisma.service'; import { JobPartialFailure } from '../src/system/job-run.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 phenologyStep: PhenologyStep; let autopilotStep: AutopilotStep; let expiryStep: SuggestionsExpiryStep; const ids: Record = {}; /** 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); phenologyStep = app.get(PhenologyStep); 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, apiaryPhenology: prisma.apiaryPhenology, $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, ) => { 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, ) => { 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('phenology step', () => { const DAY = '2001-04-30'; let weatherApiaryId = ''; beforeAll(async () => { const apiary = await prisma.apiary.create({ data: { name: `Daily Evaluation Weather ${suffix}`, latitude: 48.1, longitude: 9.9, }, }); weatherApiaryId = apiary.id; // 10 degrees every day from January 1st to April 29th: GTS 965. const days: Prisma.ApiaryWeatherDayCreateManyInput[] = []; for ( let t = d('2001-01-01').getTime(); t <= d('2001-04-29').getTime(); t += 86_400_000 ) { days.push({ apiaryId: apiary.id, day: new Date(t), meanTempC: 10, source: 'archive', }); } await prisma.apiaryWeatherDay.createMany({ data: days }); await prisma.phenologyReport.create({ data: { apiaryId: apiary.id, phase: 'HOLUNDER', date: d('2001-04-29'), userId: ids.user, }, }); }); afterAll(async () => { await prisma.domainEvent.deleteMany({ where: { aggregateId: weatherApiaryId }, }); await prisma.phenologyReport.deleteMany({ where: { apiaryId: weatherApiaryId }, }); // Rows of 2001 would feed the season of the other tests of this file. await prisma.apiaryPhenology.deleteMany({ where: { year: 2001 } }); await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } }); }); // Other files create and delete apiaries concurrently: an apiary that // vanishes mid-run fails the step, the apiaries of this file do not. const runStep = () => phenologyStep.run(DAY).catch((error: unknown) => { if (!(error instanceof JobPartialFailure)) { throw error; } return error.result; }); it('the phenology step writes ApiaryPhenology for every active apiary', async () => { const result = await runStep(); expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2); const rows = await prisma.apiaryPhenology.findMany({ where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2001 }, orderBy: { apiaryId: 'asc' }, }); expect(rows).toHaveLength(2); const byApiary = new Map(rows.map((row) => [row.apiaryId, row])); // Calendar only (no coordinates): APFEL from April 28th. expect(byApiary.get(ids.apiary)).toMatchObject({ gts: null, effectivePhase: 'APFEL', phaseSource: 'CALENDAR', segment: 'SCHWARMZEIT', }); // A report for HOLUNDER beats GTS and calendar. expect(byApiary.get(weatherApiaryId)).toMatchObject({ gts: 965, gtsThroughDay: d('2001-04-29'), estimatedPhase: 'APFEL', reportedPhase: 'HOLUNDER', effectivePhase: 'HOLUNDER', phaseSource: 'REPORT', segment: 'SCHWARMZEIT', }); }); it('records the phase change once per apiary and day', async () => { await runStep(); await runStep(); expect(await eventsOf(weatherApiaryId)).toEqual([ { type: 'SeasonPhaseChanged', aggregateType: 'Apiary', payload: { apiaryId: weatherApiaryId, phase: 'HOLUNDER', segment: 'SCHWARMZEIT', previousPhase: 'APFEL', previousSegment: 'SCHWARMZEIT', source: 'REPORT', }, dedupeKey: `SeasonPhaseChanged:${weatherApiaryId}:${DAY}`, actorUserId: null, }, ]); expect(await eventsOf(ids.apiary)).toEqual([]); }); }); describe('autopilot and suggestions-expiry steps', () => { /** Living colony, inspected 2001-05-03 and measured 2001-05-10. */ async function lateColony(name: string): Promise { 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'], ); }); }); });