- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { App } from 'supertest/types';
|
|
import { PrismaService } from '../src/prisma/prisma.service';
|
|
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
|
import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers';
|
|
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { drainDomainEvents } from './utils/domain-events';
|
|
|
|
/**
|
|
* The biotechnical chains against a real PostgreSQL: an inspection, a queen
|
|
* assignment or a split leaves one PLAN suggestion; later inspections clean
|
|
* up the checks they answer. The events are recorded directly (the services
|
|
* that record them have their own e2e tests) and drained like the worker.
|
|
*/
|
|
describe('Chains (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
let events: DomainEventsService;
|
|
let suggestions: SuggestionsService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
let apiaryId: string;
|
|
let colonyId: string;
|
|
let userId: string;
|
|
let aggregateIds: string[] = [];
|
|
|
|
// Only the chains: the season autopilot writes its own suggestions for the
|
|
// same colony when the inspection event is delivered.
|
|
const chainSuggestions = () =>
|
|
prisma.suggestion.findMany({
|
|
where: { colonyId, source: 'CHAIN' },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') =>
|
|
prisma.suggestion.findMany({
|
|
where: { colonyId, source: 'CHAIN', status: 'OPEN', intent },
|
|
});
|
|
|
|
const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10);
|
|
|
|
/** Records an inspection and runs the handlers of its event. */
|
|
async function inspect(
|
|
day: string,
|
|
findings: {
|
|
queenCellsPresent?: boolean | null;
|
|
queenSeen?: boolean | null;
|
|
eggsPresent?: boolean | null;
|
|
} = {},
|
|
): Promise<string> {
|
|
const inspection = await prisma.inspection.create({
|
|
data: {
|
|
colonyId,
|
|
userId,
|
|
date: new Date(`${day}T10:00:00.000Z`),
|
|
queenCellsPresent: findings.queenCellsPresent ?? null,
|
|
queenSeen: findings.queenSeen ?? null,
|
|
eggsPresent: findings.eggsPresent ?? null,
|
|
},
|
|
});
|
|
await recordAndDrain(inspection.id, () =>
|
|
prisma.$transaction((tx) =>
|
|
events.record(tx, {
|
|
type: 'InspectionRecorded',
|
|
aggregateType: 'Inspection',
|
|
aggregateId: inspection.id,
|
|
payload: { colonyId },
|
|
}),
|
|
),
|
|
);
|
|
return inspection.id;
|
|
}
|
|
|
|
async function assignQueen(assignedOn: string): Promise<string> {
|
|
const queenId = `queen-${suffix}-${aggregateIds.length}`;
|
|
const historyId = `hist-${suffix}-${aggregateIds.length}`;
|
|
await recordAndDrain(queenId, () =>
|
|
prisma.$transaction((tx) =>
|
|
events.record(tx, {
|
|
type: 'QueenAssigned',
|
|
aggregateType: 'Queen',
|
|
aggregateId: queenId,
|
|
payload: { colonyId, historyId, assignedOn, previousColonyId: null },
|
|
}),
|
|
),
|
|
);
|
|
return historyId;
|
|
}
|
|
|
|
async function split(
|
|
queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null,
|
|
): Promise<void> {
|
|
await recordAndDrain(colonyId, () =>
|
|
prisma.$transaction((tx) =>
|
|
events.record(tx, {
|
|
type: 'ColonySplit',
|
|
aggregateType: 'Colony',
|
|
aggregateId: colonyId,
|
|
payload: {
|
|
parentColonyId: `parent-${suffix}`,
|
|
lineageId: `lineage-${suffix}-${aggregateIds.length}`,
|
|
queenSource,
|
|
splitOn: '2026-05-01',
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function recordAndDrain(
|
|
aggregateId: string,
|
|
record: () => Promise<void>,
|
|
): Promise<void> {
|
|
aggregateIds = [...aggregateIds, aggregateId];
|
|
await record();
|
|
await drainDomainEvents(app, [aggregateId]);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
events = app.get(DomainEventsService);
|
|
suggestions = app.get(SuggestionsService);
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Chains e2e ${suffix}` },
|
|
});
|
|
apiaryId = apiary.id;
|
|
const colony = await prisma.colony.create({
|
|
data: {
|
|
code: `CHN-${suffix}`,
|
|
apiaryId,
|
|
colonyType: 'PRODUCTION_COLONY',
|
|
},
|
|
});
|
|
colonyId = colony.id;
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `chains-e2e-${suffix}@example.com`,
|
|
passwordHash: 'x',
|
|
name: 'Chains e2e',
|
|
role: { connect: { key: 'STAFF' } },
|
|
},
|
|
});
|
|
userId = user.id;
|
|
});
|
|
|
|
async function cleanUp(): Promise<void> {
|
|
await prisma.task.deleteMany({
|
|
where: {
|
|
OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }],
|
|
},
|
|
});
|
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
|
await prisma.domainEvent.deleteMany({
|
|
where: { aggregateId: { in: aggregateIds } },
|
|
});
|
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
|
aggregateIds = [];
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await cleanUp();
|
|
await prisma.colony.update({
|
|
where: { id: colonyId },
|
|
data: { status: 'ACTIVE' },
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanUp();
|
|
await prisma.auditLog.deleteMany({
|
|
where: {
|
|
OR: [
|
|
{ userId },
|
|
{
|
|
entityType: 'Suggestion',
|
|
afterData: { path: ['colonyId'], equals: colonyId },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
await prisma.colony.delete({ where: { id: colonyId } });
|
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
|
await prisma.user.delete({ where: { id: userId } });
|
|
await app.close();
|
|
});
|
|
|
|
it('queen cells -> one PLAN suggestion due +7', async () => {
|
|
const inspectionId = await inspect('2026-05-05', {
|
|
queenCellsPresent: true,
|
|
});
|
|
|
|
const open = await openOf('SWARM_CHECK');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0]).toMatchObject({
|
|
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
|
source: 'CHAIN',
|
|
mode: 'PLAN',
|
|
relatedType: 'COLONY',
|
|
relatedId: colonyId,
|
|
requiredPermission: 'inspections:read',
|
|
dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`,
|
|
});
|
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
|
|
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0);
|
|
});
|
|
|
|
it('an inspection without queen cells proposes nothing', async () => {
|
|
await inspect('2026-05-05', { queenCellsPresent: false });
|
|
await inspect('2026-05-06');
|
|
|
|
expect(await chainSuggestions()).toHaveLength(0);
|
|
});
|
|
|
|
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => {
|
|
await split('RAISE_OWN');
|
|
|
|
const [suggestion] = await openOf('SPLIT_PLAN');
|
|
expect(suggestion).toMatchObject({
|
|
ruleKey: 'SPLIT_SCHEDULE',
|
|
mode: 'PLAN',
|
|
requiredPermission: 'colonies:read',
|
|
});
|
|
expect(dayOf(suggestion.dueDate)).toBe('2026-05-10');
|
|
|
|
const result = await suggestions.accept(suggestion.id, {}, userId);
|
|
|
|
expect(
|
|
result.tasks.map((task) => [task.title, dayOf(task.dueDate)]),
|
|
).toEqual([
|
|
['Nachschaffungszellen prüfen', '2026-05-10'],
|
|
['Begattung prüfen', '2026-05-22'],
|
|
['Stifte prüfen', '2026-05-29'],
|
|
]);
|
|
});
|
|
|
|
it('a split without queenSource leaves nothing', async () => {
|
|
await split(null);
|
|
|
|
expect(await chainSuggestions()).toHaveLength(0);
|
|
});
|
|
|
|
it('a queen assignment -> acceptance check +7', async () => {
|
|
const historyId = await assignQueen('2026-05-05');
|
|
|
|
const open = await openOf('QUEEN_ACCEPTANCE');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0]).toMatchObject({
|
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
|
source: 'CHAIN',
|
|
mode: 'PLAN',
|
|
requiredPermission: 'queens:read',
|
|
dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`,
|
|
});
|
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
|
|
});
|
|
|
|
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => {
|
|
await split('QUEEN');
|
|
await assignQueen('2026-05-01');
|
|
|
|
const open = await openOf('QUEEN_ACCEPTANCE');
|
|
expect(open).toHaveLength(1);
|
|
expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE');
|
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-08');
|
|
});
|
|
|
|
it('an inspection on the due day resolves the open swarm check', async () => {
|
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
|
|
|
await inspect('2026-05-12', { queenCellsPresent: false });
|
|
|
|
const [resolved] = await chainSuggestions();
|
|
expect(resolved).toMatchObject({
|
|
status: 'RESOLVED',
|
|
dedupeKey: null,
|
|
decisionNote: 'Mit Durchsicht erledigt',
|
|
});
|
|
});
|
|
|
|
it('an inspection before the due day leaves the swarm check open', async () => {
|
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
|
|
|
await inspect('2026-05-08', { queenCellsPresent: false });
|
|
|
|
expect(await openOf('SWARM_CHECK')).toHaveLength(1);
|
|
});
|
|
|
|
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => {
|
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
|
|
|
const secondId = await inspect('2026-05-08', { queenCellsPresent: true });
|
|
|
|
const all = await chainSuggestions();
|
|
expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']);
|
|
expect(all[1].dedupeKey).toBe(
|
|
`QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`,
|
|
);
|
|
expect(dayOf(all[1].dueDate)).toBe('2026-05-15');
|
|
});
|
|
|
|
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => {
|
|
await assignQueen('2026-05-05');
|
|
|
|
await inspect('2026-05-12');
|
|
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1);
|
|
|
|
await inspect('2026-05-13', { eggsPresent: true });
|
|
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0);
|
|
});
|
|
|
|
it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => {
|
|
const inspectionId = await inspect('2026-05-05', {
|
|
queenCellsPresent: true,
|
|
});
|
|
await split('RAISE_OWN');
|
|
const handlers = app.get(ChainEventHandlers);
|
|
const events = await prisma.domainEvent.findMany({
|
|
where: { aggregateId: { in: aggregateIds } },
|
|
});
|
|
expect(events).toHaveLength(2);
|
|
|
|
// Redelivery after a crash: the handlers run again with the stored events.
|
|
for (const event of events) {
|
|
await prisma.$transaction(async (tx) => {
|
|
const envelope = { ...event, attempts: 1 } as never;
|
|
if (event.type === 'InspectionRecorded') {
|
|
await handlers.queenCells(envelope, tx);
|
|
} else {
|
|
await handlers.splitSchedule(envelope, tx);
|
|
}
|
|
});
|
|
}
|
|
|
|
const all = await chainSuggestions();
|
|
expect(all.map((row) => [row.intent, row.status])).toEqual([
|
|
['SWARM_CHECK', 'OPEN'],
|
|
['SPLIT_PLAN', 'OPEN'],
|
|
]);
|
|
expect(all[0].dedupeKey).toContain(inspectionId);
|
|
});
|
|
|
|
it('a backdated inspection with queen cells does not replace the newer open check', async () => {
|
|
await inspect('2026-05-08', { queenCellsPresent: true });
|
|
|
|
await inspect('2026-05-02', { queenCellsPresent: true });
|
|
|
|
const all = await chainSuggestions();
|
|
expect(all.map((row) => row.status)).toEqual(['OPEN']);
|
|
expect(dayOf(all[0].dueDate)).toBe('2026-05-15');
|
|
});
|
|
|
|
it('nothing for an ended colony', async () => {
|
|
await prisma.colony.update({
|
|
where: { id: colonyId },
|
|
data: { status: 'DISSOLVED' },
|
|
});
|
|
|
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
|
await assignQueen('2026-05-05');
|
|
await split('QUEEN');
|
|
|
|
expect(await chainSuggestions()).toHaveLength(0);
|
|
});
|
|
});
|