import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; import { drainDomainEvents } from './utils/domain-events'; interface RuleBody { id: string; triggerType: string; active: boolean; } /** * Follow-up checks after a treatment or feeding: off by default (no built-in * intervals), due relative to the measure itself, never duplicated. */ describe('Follow-up controls (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; let token: string; let apiaryId: string; const colonyIds: string[] = []; const server = () => app.getHttpServer(); const auth = () => ({ Authorization: `Bearer ${token}` }); async function rule(triggerType: string): Promise { const response = await request(server()) .get('/api/v1/automation-rules') .set(auth()) .expect(200); const found = (response.body as RuleBody[]).find( (r) => r.triggerType === triggerType, ); if (!found) throw new Error(`rule ${triggerType} missing`); return found; } async function newColony(code: string) { const colony = await prisma.colony.create({ data: { code: `${code}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); return colony; } // The follow-up check comes from the TreatmentRecorded handler, not from // the request: the helper drains the events of this treatment. async function treat(colonyId: string, body: Record = {}) { const response = await request(server()) .post('/api/v1/treatments') .set(auth()) .send({ colonyId, date: '2026-08-01T00:00:00.000Z', product: 'Testmittel', ...body, }) .expect(201); await drainDomainEvents(app, [(response.body as { id: string }).id]); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); await prisma.user.create({ data: { email: `followup-${suffix}@example.com`, passwordHash: await bcrypt.hash('SuperSecret123!', 12), name: 'Follow-up', role: { connect: { key: 'ADMIN' } }, }, }); token = ( await login(app, `followup-${suffix}@example.com`, 'SuperSecret123!') ).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Follow-up ${suffix}` } }) ).id; }); afterAll(async () => { // Leave the shared rules as other suites expect them: off. await prisma.automationRule.updateMany({ where: { triggerType: { in: ['TREATMENT_FOLLOW_UP', 'FEEDING_FOLLOW_UP'] }, }, data: { active: false, config: {} }, }); // Follow-up suggestions are written without a user: remove them with // their audit entries (deliveries go with the event, ON DELETE CASCADE). const suggestionIds = ( await prisma.suggestion.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, }) ).map((suggestion) => suggestion.id); await prisma.auditLog.deleteMany({ where: { OR: [ { entityType: 'Suggestion', entityId: { in: suggestionIds } }, ...colonyIds.map((id) => ({ entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: id }, })), ], }, }); await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, }); await prisma.suggestion.deleteMany({ where: { id: { in: suggestionIds } }, }); const measureIds = [ ...(await prisma.treatment.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, })), ...(await prisma.feeding.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, })), ].map((measure) => measure.id); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: measureIds } }, }); await prisma.auditLog.deleteMany({ where: { entityId: { in: measureIds } }, }); await app.close(); }); it('ships both follow-up rules switched off, without a built-in interval', async () => { const treatmentRule = await rule('TREATMENT_FOLLOW_UP'); const feedingRule = await rule('FEEDING_FOLLOW_UP'); expect(treatmentRule.active).toBe(false); expect(feedingRule.active).toBe(false); const colony = await newColony('OFF'); await treat(colony.id); // Only the follow-up rules are meant; the autopilot proposes on its own. expect( await prisma.suggestion.count({ where: { colonyId: colony.id, source: 'RULE' }, }), ).toBe(0); }); it('suggests a check due the configured days after the treatment ended', async () => { const treatmentRule = await rule('TREATMENT_FOLLOW_UP'); await request(server()) .patch(`/api/v1/automation-rules/${treatmentRule.id}`) .set(auth()) .send({ active: true, config: { dueInDays: 14 } }) .expect(200); const colony = await newColony('TREAT'); await treat(colony.id, { startDate: '2026-08-01T00:00:00.000Z', endDate: '2026-08-10T00:00:00.000Z', }); const suggestions = await prisma.suggestion.findMany({ where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' }, }); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ status: 'OPEN', intent: 'FOLLOW_UP', reasonCode: 'TREATMENT_FOLLOW_UP', requiredPermission: 'treatments:read', evidence: { colonyId: colony.id, endedOn: '2026-08-10' }, }); expect(suggestions[0].dueDate?.toISOString()).toBe( '2026-08-24T00:00:00.000Z', ); // The rule proposes; no task exists before the suggestion is accepted. expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe( 0, ); // A second treatment while the check is still open adds nothing. await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' }); expect( await prisma.suggestion.count({ where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' }, }), ).toBe(1); }); it('suggests a check after a feeding, counted from the feeding date', async () => { const feedingRule = await rule('FEEDING_FOLLOW_UP'); await request(server()) .patch(`/api/v1/automation-rules/${feedingRule.id}`) .set(auth()) .send({ active: true, config: { dueInDays: 7 } }) .expect(200); const colony = await newColony('FEED'); const response = await request(server()) .post('/api/v1/feedings') .set(auth()) .send({ colonyId: colony.id, date: '2026-09-01T00:00:00.000Z', feedType: 'SUGAR_WATER', amount: 5, unit: 'l', }) .expect(201); // Not before the FeedingRecorded handler ran. expect( await prisma.suggestion.count({ where: { colonyId: colony.id } }), ).toBe(0); await drainDomainEvents(app, [(response.body as { id: string }).id]); const suggestion = await prisma.suggestion.findFirstOrThrow({ where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' }, }); expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z'); expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' }); }); });