- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
755 lines
24 KiB
TypeScript
755 lines
24 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 { 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<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[] = [];
|
|
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<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),
|
|
...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<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([]);
|
|
});
|
|
|
|
/** The stored season of the e2e apiary in 2001: KIRSCHE by report. */
|
|
async function reportKirsche(forApiaryId = apiaryId): Promise<void> {
|
|
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();
|
|
}
|
|
});
|
|
});
|