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; 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 { 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 { 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 { 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, ): Promise { 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 { 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); }); });