feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- 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]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -0,0 +1,365 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user