- Authority notice PDFs: pure model plus renderer, TSK count in A4 landscape, FEHLT for missing particulars, stored totals with correction note; GET /authority-notices/:id/pdf with compliance:read. - Compliance notifications in category COMPLIANCE: herd change and TSK reference day, at most one unread per topic and user (advisory lock); daily rule TskReferenceDayReached with Berlin day and catch-up; handlers in API and worker. - Readiness core: live evaluation of seven pure rules, fingerprinted acknowledgements that reopen when revoked or stale, subject masking, read-only GET paths; READINESS_RULES injectable; kept out of the worker. - e2e hygiene: no unscoped deletes, seeded settings rows saved/restored. - ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
690 lines
23 KiB
TypeScript
690 lines
23 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 { ComplianceModule } from '../src/compliance/compliance.module';
|
|
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';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
|
|
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,
|
|
TskReferenceDayReached: 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<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({
|
|
// The compliance handlers turn the TSK reference day into a draft.
|
|
imports: [JobsModule, ComplianceModule],
|
|
}).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<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([]);
|
|
});
|
|
|
|
describe('TSK reference day', () => {
|
|
let savedReferenceDay: string;
|
|
let permittedUsers: number;
|
|
|
|
beforeEach(async () => {
|
|
// The seeded singleton stays: only its reference day is borrowed.
|
|
const saved = await prisma.complianceSettings.upsert({
|
|
where: { id: 'singleton' },
|
|
update: {},
|
|
create: { id: 'singleton' },
|
|
});
|
|
savedReferenceDay = saved.tskReferenceDay;
|
|
await prisma.complianceSettings.update({
|
|
where: { id: 'singleton' },
|
|
data: { tskReferenceDay: '03-03' },
|
|
});
|
|
permittedUsers = await prisma.user.count({
|
|
where: {
|
|
isActive: true,
|
|
role: {
|
|
status: 'ACTIVE',
|
|
permissions: { some: { permission: { key: 'compliance:manage' } } },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await prisma.notification.deleteMany({
|
|
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
|
|
});
|
|
await prisma.authorityNotice.deleteMany({ where: { year: 2001 } });
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateId: 'tsk:2001' },
|
|
});
|
|
await prisma.complianceSettings.update({
|
|
where: { id: 'singleton' },
|
|
data: { tskReferenceDay: savedReferenceDay },
|
|
});
|
|
});
|
|
|
|
async function expectOneDraftAndNotices(): Promise<void> {
|
|
expect(await eventsOf('tsk:2001')).toEqual([
|
|
{
|
|
type: 'TskReferenceDayReached',
|
|
aggregateType: 'Compliance',
|
|
payload: { year: 2001, referenceDay: '2001-03-03' },
|
|
dedupeKey: 'TskReferenceDayReached:tsk:2001:2001-03-03',
|
|
actorUserId: null,
|
|
},
|
|
]);
|
|
const drafts = await prisma.authorityNotice.findMany({
|
|
where: { kind: 'TSK_REPORT', year: 2001 },
|
|
});
|
|
expect(drafts).toHaveLength(1);
|
|
expect(drafts[0]).toMatchObject({
|
|
status: 'DRAFT',
|
|
openKey: 'TSK_REPORT:2001',
|
|
});
|
|
const notifications = await prisma.notification.findMany({
|
|
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
|
|
select: { userId: true, priority: true, category: true, message: true },
|
|
});
|
|
expect(notifications).toHaveLength(permittedUsers);
|
|
expect(new Set(notifications.map((row) => row.userId)).size).toBe(
|
|
permittedUsers,
|
|
);
|
|
for (const row of notifications) {
|
|
expect(row).toMatchObject({
|
|
priority: 'WARNUNG',
|
|
category: 'COMPLIANCE',
|
|
});
|
|
expect(row.message).toMatch(
|
|
/^TSK-Stichtag 03\.03\.2001: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
|
|
);
|
|
}
|
|
}
|
|
|
|
it('daily evaluation twice on the reference day: one event, one draft', async () => {
|
|
const first = await step.run('2001-03-03');
|
|
const second = await step.run('2001-03-03');
|
|
await drainDomainEvents(app, ['tsk:2001']);
|
|
await step.run('2001-03-04');
|
|
await drainDomainEvents(app, ['tsk:2001']);
|
|
|
|
expect(first.TskReferenceDayReached).toBe(1);
|
|
expect(second.TskReferenceDayReached).toBe(0);
|
|
await expectOneDraftAndNotices();
|
|
});
|
|
|
|
it('catches up a missed reference day on the 5th exactly once', async () => {
|
|
const catchUp = await step.run('2001-03-05');
|
|
const again = await step.run('2001-03-05');
|
|
await drainDomainEvents(app, ['tsk:2001']);
|
|
|
|
expect(catchUp.TskReferenceDayReached).toBe(1);
|
|
expect(again.TskReferenceDayReached).toBe(0);
|
|
await expectOneDraftAndNotices();
|
|
});
|
|
});
|
|
|
|
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<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'],
|
|
);
|
|
});
|
|
});
|
|
});
|