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 { AutomationTriggerType, Prisma } from '../generated/prisma'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; import { drainDomainEvents } from './utils/domain-events'; import { DEFAULT_RULES } from '../src/automation/rule-config'; interface AutomationRuleBody { id: string; triggerType: string; active: boolean; config: Record | null; actionTemplate: string | null; } describe('Automation rules (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const adminEmail = `automation-e2e-admin-${Date.now()}@example.com`; const staffEmail = `automation-e2e-staff-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let adminId: string; let staffId: string; let adminToken: string; let staffToken: string; let apiaryId: string; let colonyId: string; const followColonyIds: string[] = []; let varroaRuleId: string; let swarmRuleId: string; /** * Records an inspection or varroa measurement and runs the handlers of * its event - the rules evaluate there, not in the request. */ async function record(path: string, body: object): Promise { const response = await request(app.getHttpServer()) .post(`/api/v1/${path}`) .set( 'Authorization', `Bearer ${path === 'inspections' ? staffToken : adminToken}`, ) .send({ colonyId, date: new Date().toISOString(), ...body }) .expect(201); const id = (response.body as { id: string }).id; await drainDomainEvents(app, [id]); return id; } const suggestionsOf = (ruleKey?: AutomationTriggerType) => prisma.suggestion.findMany({ where: { colonyId, source: 'RULE', ...(ruleKey ? { ruleKey } : {}) }, orderBy: { createdAt: 'asc' }, }); // Other sources (the season autopilot) propose on the same events. const ruleSuggestionCount = () => prisma.suggestion.count({ where: { colonyId, source: 'RULE' } }); const tasksOfColony = () => prisma.task.count({ where: { relatedId: colonyId } }); // Settles every open suggestion, so the one-open-thing guard cannot be // what keeps a new one from appearing - only the missing evaluation may. const dismissOpen = () => prisma.suggestion.updateMany({ where: { colonyId, status: 'OPEN' }, data: { status: 'DISMISSED' }, }); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const admin = await prisma.user.create({ data: { email: adminEmail, passwordHash, name: 'Automation E2E Admin', role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; const staff = await prisma.user.create({ data: { email: staffEmail, passwordHash, name: 'Automation E2E Staff', role: { connect: { key: 'STAFF' } }, }, }); staffId = staff.id; adminToken = (await login(app, adminEmail, password)).accessToken; staffToken = (await login(app, staffEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Automation Stand ${Date.now()}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `AUTO-${Date.now()}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; }); afterAll(async () => { // Rule suggestions and the rules' own entries are written without a // user - they are not covered by the userId cleanup below. Deliveries go // with their event (ON DELETE CASCADE). const colonyIds = [colonyId, ...followColonyIds]; const suggestionIds = ( await prisma.suggestion.findMany({ where: { colonyId }, select: { id: true }, }) ).map((suggestion) => suggestion.id); const byColony = { where: { colonyId: { in: colonyIds } }, select: { id: true }, }; const recordIds = [ ...(await prisma.inspection.findMany(byColony)), ...(await prisma.varroaMeasurement.findMany(byColony)), ].map((record) => record.id); await prisma.auditLog.deleteMany({ where: { OR: [ { entityType: 'Suggestion', entityId: { in: suggestionIds } }, ...colonyIds.map((id) => ({ entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: id }, })), ], }, }); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: recordIds } }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceId: { in: recordIds } }, }); await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.varroaMeasurement.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.swarmControlCheck.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.inspection.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [adminId, staffId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [adminId, staffId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [adminId, staffId] } } }); await app.close(); }); it('rejects a non-admin listing automation rules', () => { return request(app.getHttpServer()) .get('/api/v1/automation-rules') .set('Authorization', `Bearer ${staffToken}`) .expect(403); }); it('seeds every fixed trigger for an admin', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/automation-rules') .set('Authorization', `Bearer ${adminToken}`) .expect(200); const rules = response.body as AutomationRuleBody[]; const triggerTypes = rules.map((rule) => rule.triggerType).sort(); expect(triggerTypes).toEqual( [...Object.values(AutomationTriggerType)].sort(), ); const varroaRule = rules.find( (rule) => rule.triggerType === 'VARROA_THRESHOLD', ); expect(varroaRule).toBeDefined(); varroaRuleId = varroaRule!.id; swarmRuleId = rules.find( (rule) => rule.triggerType === 'SWARM_MOOD_HIGH', )!.id; }); const NEW_TRIGGERS = [ 'INSPECTION_INTERVAL', 'QUEEN_CELLS_SWARM_CHECK', 'QUEEN_ACCEPTANCE_CHECK', 'SPLIT_SCHEDULE', 'COLONY_ENDED_CLEANUP', 'WITHDRAWAL_ENDED', 'HONEY_WATER_CONTENT', 'SEASON_AUTOPILOT', 'PHENOLOGY', ] as const; it('lists the nine new rules with their defaults', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/automation-rules') .set('Authorization', `Bearer ${adminToken}`) .expect(200); const rules = response.body as AutomationRuleBody[]; for (const trigger of NEW_TRIGGERS) { const rule = rules.find((entry) => entry.triggerType === trigger); expect(rule).toBeDefined(); expect(rule!.active).toBe(DEFAULT_RULES[trigger].active ?? true); expect(rule!.config).toEqual(DEFAULT_RULES[trigger].config); expect(rule!.actionTemplate).toBe(''); } // Existing rules keep their stored config; a fresh database has the new defaults. const varroa = rules.find( (rule) => rule.triggerType === 'VARROA_THRESHOLD', ); expect(varroa?.config).toBeTruthy(); }); it('PATCH a rule with an invalid known value answers 400 with the field', async () => { const rules = ( await request(app.getHttpServer()) .get('/api/v1/automation-rules') .set('Authorization', `Bearer ${adminToken}`) .expect(200) ).body as AutomationRuleBody[]; const autopilot = rules.find( (rule) => rule.triggerType === 'SEASON_AUTOPILOT', )!; const res = await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${autopilot.id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ config: { queenMaxAgeYears: 0 } }) .expect(400); const body = res.body as { message: string; issues: { field: string; message: string }[]; }; expect(body.message).toBe('Die Einstellung ist ungültig.'); expect(body.issues).toEqual([ expect.objectContaining({ field: 'queenMaxAgeYears' }), ]); // Nothing was written. const stored = await prisma.automationRule.findUniqueOrThrow({ where: { id: autopilot.id }, }); expect(stored.config).toEqual(autopilot.config); }); it('PATCH keeps unknown keys and saves a valid config', async () => { const rule = await prisma.automationRule.findUniqueOrThrow({ where: { triggerType: 'SEASON_AUTOPILOT' }, }); try { await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${rule.id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ config: { queenMaxAgeYears: 3, futureKey: 'x' } }) .expect(200); const stored = await prisma.automationRule.findUniqueOrThrow({ where: { id: rule.id }, }); expect(stored.config).toEqual({ queenMaxAgeYears: 3, futureKey: 'x' }); } finally { await prisma.automationRule.update({ where: { id: rule.id }, data: { config: rule.config as Prisma.InputJsonValue }, }); } }); it('proposes a queen check and notifies when queen not seen and no eggs present', async () => { const inspectionId = await record('inspections', { queenSeen: false, eggsPresent: false, }); const suggestions = await suggestionsOf(); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ ruleKey: 'QUEENLESS_NO_EGGS', source: 'RULE', mode: 'PLAN', intent: 'QUEEN_CHECK', status: 'OPEN', priority: 'HIGH', reasonCode: 'QUEENLESS_NO_EGGS', requiredPermission: 'inspections:read', dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`, }); // The rule proposes, it creates no task. expect(await tasksOfColony()).toBe(0); const notifications = await prisma.notification.findMany({ where: { relatedType: 'Suggestion', relatedId: suggestions[0].id }, }); // One copy per user who may read tasks and inspections, never a broadcast. expect(notifications.length).toBeGreaterThan(0); expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true); expect(new Set(notifications.map((n) => n.userId)).size).toBe( notifications.length, ); }); it('does not trigger when the queen was seen', async () => { await record('inspections', { queenSeen: true, eggsPresent: false }); expect(await suggestionsOf()).toHaveLength(1); }); it('proposes feeding when feed status is kritisch, without a notification', async () => { await record('inspections', { feedStatus: 'CRITICAL' }); const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL'); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' }); // The rule is off for notifications by default (notify: false). expect( await prisma.notification.count({ where: { relatedType: 'Suggestion', relatedId: suggestions[0].id }, }), ).toBe(0); expect(await tasksOfColony()).toBe(0); }); it('evaluates no rule on a corrected inspection', async () => { const id = await record('inspections', { queenSeen: true }); await dismissOpen(); const before = await ruleSuggestionCount(); await request(app.getHttpServer()) .patch(`/api/v1/inspections/${id}`) .set('Authorization', `Bearer ${staffToken}`) .send({ queenSeen: false, eggsPresent: false, feedStatus: 'CRITICAL' }) .expect(200); await drainDomainEvents(app, [id]); expect(await ruleSuggestionCount()).toBe(before); }); it('proposes a treatment check when the mite fall is red', async () => { const id = await record('varroa-measurements', { method: 'NATURAL_MITE_FALL', miteCount: 20, date: '2026-08-10T00:00:00.000Z', // One day on the board: 20 mites per day, above the limit of 10. durationMinutes: 24 * 60, }); const suggestions = await suggestionsOf('VARROA_THRESHOLD'); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ intent: 'VARROA_TREAT', reasonCode: 'VARROA_RED', status: 'OPEN', requiredPermission: 'varroa:read', evidence: { colonyId, measurementId: id, measuredOn: '2026-08-10', value: 20, redAbove: 10, unit: 'MITES_PER_DAY', }, }); expect(await tasksOfColony()).toBe(0); }); it('rates a sample-based count with the percentage band', async () => { await dismissOpen(); const before = await ruleSuggestionCount(); // Summer band: red above 5 %. 5 of 500 bees is 1 %: yellow, no proposal. await record('varroa-measurements', { method: 'POWDERED_SUGAR', miteCount: 5, sampleBees: 500, date: '2026-07-10T00:00:00.000Z', }); expect(await ruleSuggestionCount()).toBe(before); // 50 of 500 bees is 10 %: red. await record('varroa-measurements', { method: 'POWDERED_SUGAR', miteCount: 50, sampleBees: 500, date: '2026-07-11T00:00:00.000Z', }); const red = await prisma.suggestion.findMany({ where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' }, }); expect(red).toHaveLength(1); expect(red[0].evidence).toMatchObject({ unit: 'PERCENT', value: 10 }); }); it('re-evaluates a corrected varroa measurement against the limit', async () => { await dismissOpen(); const before = await ruleSuggestionCount(); // Five mites per day: yellow in August, no proposal. const id = await record('varroa-measurements', { method: 'NATURAL_MITE_FALL', miteCount: 5, date: '2026-08-20T00:00:00.000Z', durationMinutes: 24 * 60, }); expect(await ruleSuggestionCount()).toBe(before); // The count was miscounted: 25 mites per day. await request(app.getHttpServer()) .patch(`/api/v1/varroa-measurements/${id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ miteCount: 25 }) .expect(200); await drainDomainEvents(app, [id]); const open = await prisma.suggestion.findMany({ where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' }, }); expect(open).toHaveLength(1); expect(open[0].dedupeKey).toBe(`VARROA_THRESHOLD:${colonyId}:${id}`); expect(await ruleSuggestionCount()).toBe(before + 1); // Corrected again while still red: the open suggestion follows the value. const correct = async (miteCount: number) => { await request(app.getHttpServer()) .patch(`/api/v1/varroa-measurements/${id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ miteCount }) .expect(200); await drainDomainEvents(app, [id]); }; await correct(30); const refreshed = await prisma.suggestion.findUniqueOrThrow({ where: { id: open[0].id }, }); expect(refreshed.status).toBe('OPEN'); expect(refreshed.evidence).toMatchObject({ value: 30 }); // Corrected below the limit: the condition is gone, the suggestion resolves. await correct(1); const resolved = await prisma.suggestion.findUniqueOrThrow({ where: { id: open[0].id }, }); expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null }); }); describe('an open inspection suggestion follows newer inspections', () => { const ANCHOR_DAY = '2026-05-05T00:00:00.000Z'; async function freshColony(): Promise { const colony = await prisma.colony.create({ data: { code: `AUTO-F-${Date.now()}-${followColonyIds.length}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); followColonyIds.push(colony.id); return colony.id; } async function inspectOn( forColony: string, date: string, body: object, ): Promise { const response = await request(app.getHttpServer()) .post('/api/v1/inspections') .set('Authorization', `Bearer ${staffToken}`) .send({ colonyId: forColony, date, ...body }) .expect(201); const id = (response.body as { id: string }).id; await drainDomainEvents(app, [id]); return id; } const queenlessOf = (forColony: string) => prisma.suggestion.findMany({ where: { colonyId: forColony, ruleKey: 'QUEENLESS_NO_EGGS' }, }); const queenless = { queenSeen: false, eggsPresent: false }; const clean = { queenSeen: true, eggsPresent: true }; it('a newer clean inspection resolves it', async () => { const forColony = await freshColony(); await inspectOn(forColony, ANCHOR_DAY, queenless); expect(await queenlessOf(forColony)).toMatchObject([{ status: 'OPEN' }]); await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean); expect(await queenlessOf(forColony)).toMatchObject([ { status: 'RESOLVED', dedupeKey: null }, ]); }); it('a newer inspection with the finding keeps one OPEN suggestion with updated evidence', async () => { const forColony = await freshColony(); await inspectOn(forColony, ANCHOR_DAY, queenless); const newerId = await inspectOn( forColony, '2026-05-12T00:00:00.000Z', queenless, ); const suggestions = await queenlessOf(forColony); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ status: 'OPEN', evidence: { inspectionId: newerId, inspectedOn: '2026-05-12' }, }); }); it('a backdated inspection changes nothing', async () => { const forColony = await freshColony(); await inspectOn(forColony, ANCHOR_DAY, queenless); const [before] = await queenlessOf(forColony); await inspectOn(forColony, '2026-04-20T00:00:00.000Z', clean); const [after] = await queenlessOf(forColony); expect(after).toMatchObject({ status: 'OPEN', evidence: before.evidence, }); }); it('correcting the inspection of the finding keeps it OPEN while the finding remains', async () => { const forColony = await freshColony(); const id = await inspectOn(forColony, ANCHOR_DAY, queenless); await request(app.getHttpServer()) .patch(`/api/v1/inspections/${id}`) .set('Authorization', `Bearer ${staffToken}`) .send({ queenSeen: false, eggsPresent: false, notes: 'korrigiert' }) .expect(200); await drainDomainEvents(app, [id]); expect(await queenlessOf(forColony)).toMatchObject([ { status: 'OPEN', evidence: { inspectionId: id } }, ]); }); it('correcting an older inspection than the latest changes nothing', async () => { const forColony = await freshColony(); const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless); await inspectOn(forColony, '2026-05-12T00:00:00.000Z', queenless); const [before] = await queenlessOf(forColony); await request(app.getHttpServer()) .patch(`/api/v1/inspections/${olderId}`) .set('Authorization', `Bearer ${staffToken}`) .send({ queenSeen: true, eggsPresent: true }) .expect(200); await drainDomainEvents(app, [olderId]); const [after] = await queenlessOf(forColony); expect(after).toMatchObject({ status: 'OPEN', evidence: before.evidence, }); }); it('a late retry of an older inspection after a newer clean one proposes nothing', async () => { const forColony = await freshColony(); const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless); await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean); expect(await queenlessOf(forColony)).toMatchObject([ { status: 'RESOLVED' }, ]); // The delivery of the older inspection runs again. await prisma.domainEvent.updateMany({ where: { aggregateId: olderId }, data: { status: 'PENDING', processedAt: null, claimedAt: null }, }); await prisma.domainEventDelivery.updateMany({ where: { handler: 'automation.inspection-rules', event: { aggregateId: olderId }, }, data: { status: 'PENDING', attempts: 0, completedAt: null }, }); await drainDomainEvents(app, [olderId]); expect(await queenlessOf(forColony)).toMatchObject([ { status: 'RESOLVED' }, ]); }); it('the feed rule follows newer inspections the same way', async () => { const forColony = await freshColony(); await inspectOn(forColony, ANCHOR_DAY, { ...clean, feedStatus: 'CRITICAL', }); const feedOf = () => prisma.suggestion.findMany({ where: { colonyId: forColony, ruleKey: 'FEED_STATUS_CRITICAL' }, }); expect(await feedOf()).toMatchObject([{ status: 'OPEN' }]); const stillId = await inspectOn(forColony, '2026-05-12T00:00:00.000Z', { ...clean, feedStatus: 'CRITICAL', }); expect(await feedOf()).toMatchObject([ { status: 'OPEN', evidence: { inspectionId: stillId } }, ]); await inspectOn(forColony, '2026-05-19T00:00:00.000Z', { ...clean, feedStatus: 'GOOD', }); expect(await feedOf()).toMatchObject([{ status: 'RESOLVED' }]); }); it('correcting the inspection of the finding resolves it', async () => { const forColony = await freshColony(); const id = await inspectOn(forColony, ANCHOR_DAY, queenless); await request(app.getHttpServer()) .patch(`/api/v1/inspections/${id}`) .set('Authorization', `Bearer ${staffToken}`) .send({ queenSeen: true, eggsPresent: true }) .expect(200); await drainDomainEvents(app, [id]); expect(await queenlessOf(forColony)).toMatchObject([ { status: 'RESOLVED' }, ]); }); }); it('lets an admin clear a rule config, which falls back to its defaults', async () => { const before = await prisma.automationRule.findUniqueOrThrow({ where: { id: varroaRuleId }, }); try { const response = await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${varroaRuleId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ config: null }) .expect(200); expect((response.body as { config: unknown }).config).toBeNull(); await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${varroaRuleId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ active: null }) .expect(400); } finally { // The rule is shared by every suite - put its config back. await prisma.automationRule.update({ where: { id: varroaRuleId }, data: { config: before.config === null ? Prisma.DbNull : (before.config as Prisma.InputJsonValue), }, }); } }); /** * Deactivates SWARM_MOOD_HIGH, not a rule other e2e files rely on: the * rule rows are shared, and switching off VARROA_THRESHOLD here would make * a parallel file's varroa test see no task. Only swarm-control.e2e * evaluates this rule, with checks that never trigger it (LOW or none). */ it('lets an admin deactivate a rule, preventing further triggers', async () => { const highSwarmMood = () => request(app.getHttpServer()) .post('/api/v1/swarm-control-checks') .set('Authorization', `Bearer ${adminToken}`) .send({ colonyId, date: new Date().toISOString(), swarmMood: 'HIGH', }) .expect(201); const swarmSuggestions = () => prisma.suggestion.count({ where: { colonyId, ruleKey: 'SWARM_MOOD_HIGH' }, }); // While active, high swarm mood leaves a suggestion - the rule really // fires. (Swarm checks stay on the post-commit path: evaluated before // the response, no event to drain.) await highSwarmMood(); expect(await swarmSuggestions()).toBe(1); // Settle it so the one-open-thing guard cannot be what keeps a new one // from appearing - only the deactivation may. await dismissOpen(); try { await request(app.getHttpServer()) .patch(`/api/v1/automation-rules/${swarmRuleId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ active: false }) .expect(200); await highSwarmMood(); expect(await swarmSuggestions()).toBe(1); } finally { await prisma.automationRule.update({ where: { id: swarmRuleId }, data: { active: true }, }); } }); });