import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import type { Prisma } from '../generated/prisma'; import { NextStepsService } from '../src/autopilot/next-steps.service'; import { addDays, berlinToday } from '../src/common/dates'; import { PrismaService } from '../src/prisma/prisma.service'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); /** The example of Spec 8.5: colony B3 on 12 May, in the swarm season by calendar. */ const EXAMPLE_DAY = '2026-05-12'; interface StepBody { key: string; kind: string; intent: string | null; title: string; reasons: string[]; dueOn: string | null; urgency: string; action: { type: string; form?: string; href?: string; suggestionId?: string }; dismissible: boolean; } interface NextStepsBody { colonyId: string; segment: string; inspectionDueOn: string | null; withdrawalUntil: string | null; steps: StepBody[]; } interface TodayBody { nextStep: { colonyId: string } | null; inspectionDue: { apiaryId: string }[]; suggestionsOpen: number; seasons: { apiaryId: string; segment: string }[]; tasksVisible: boolean; } /** * The next steps and the day overview against a real PostgreSQL. The * assertions about order use the service with the fixed example day; the * routes are checked for what the user may see, which does not depend on the * day. Everything is scoped to this file's own apiary and colonies. */ describe('Next steps (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let nextSteps: NextStepsService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; const FULL = [ 'colonies:read', 'tasks:read', 'tasks:create', 'inspections:read', 'varroa:read', 'treatments:read', ]; let apiaryId: string; let colonyId: string; let recorderId: string; const roleIds: string[] = []; const userIds: string[] = []; const users = {} as Record< 'full' | 'noVarroa' | 'noCreate' | 'noTasks', { id: string; token: string } >; async function createUser( label: string, permissions: string[], ): Promise<{ id: string; token: string }> { const role = await prisma.role.create({ data: { key: `NEXT_STEPS_${label.toUpperCase()}_${suffix}`, name: `Next steps ${label}`, permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); const email = `next-steps-${label}-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: `Next steps ${label}`, role: { connect: { id: role.id } }, }, }); userIds.push(user.id); return { id: user.id, token: (await login(app, email, password)).accessToken, }; } function suggestion( overrides: Partial, ): Prisma.SuggestionUncheckedCreateInput { return { ruleKey: 'INSPECTION_INTERVAL', source: 'RULE', mode: 'DO', intent: 'INSPECT', relatedType: 'COLONY', relatedId: colonyId, colonyId, reasonCode: 'NEXT_STEPS_E2E', evidence: { colonyId }, requiredPermission: 'inspections:read', dueDate: d(EXAMPLE_DAY), ...overrides, }; } /** The three suggestions of the spec example. */ const exampleSuggestions = (): Prisma.SuggestionUncheckedCreateInput[] => [ suggestion({ source: 'AUTOPILOT', mode: 'DO', intent: 'INSPECT', priority: 'HIGH', reasonCode: 'INSPECTION_DUE', evidence: { colonyId, inspectionId: 'none', lastInspectedOn: '2026-05-04', dueOn: '2026-05-11', intervalDays: 7, segment: 'SCHWARMZEIT', }, dueDate: d('2026-05-11'), }), suggestion({ ruleKey: 'QUEEN_CELLS_SWARM_CHECK', source: 'CHAIN', mode: 'PLAN', intent: 'SWARM_CHECK', reasonCode: 'QUEEN_CELLS_SWARM_CHECK', evidence: { colonyId, inspectionId: 'insp-1', inspectedOn: '2026-05-05', baseDate: '2026-05-05', }, }), suggestion({ ruleKey: 'VARROA_THRESHOLD', mode: 'PLAN', intent: 'VARROA_TREAT', reasonCode: 'VARROA_RED', evidence: { colonyId, measurementId: 'm-1', measuredOn: '2026-05-10', value: 12.4, redAbove: 10, unit: 'MITES_PER_DAY', }, requiredPermission: 'varroa:read', }), suggestion({ ruleKey: 'SEASON_AUTOPILOT', source: 'AUTOPILOT', intent: 'HONEY_SUPER', reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER', dueDate: d('2026-05-14'), }), ]; const get = (token: string, path: string) => request(app.getHttpServer()) .get(`/api/v1${path}`) .set('Authorization', `Bearer ${token}`); const cleanDb = async () => { await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.task.deleteMany({ where: { relatedType: 'COLONY', relatedId: colonyId }, }); await prisma.treatment.deleteMany({ where: { colonyId } }); }; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); nextSteps = app.get(NextStepsService); apiaryId = ( await prisma.apiary.create({ data: { name: `Next steps ${suffix}` } }) ).id; colonyId = ( await prisma.colony.create({ data: { code: `B3-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', createdAt: d('2026-04-01'), }, }) ).id; users.full = await createUser('full', FULL); users.noVarroa = await createUser( 'novarroa', FULL.filter((key) => key !== 'varroa:read'), ); users.noCreate = await createUser( 'nocreate', FULL.filter((key) => key !== 'tasks:create'), ); users.noTasks = await createUser( 'notasks', FULL.filter((key) => !key.startsWith('tasks:')), ); recorderId = users.full.id; await prisma.inspection.create({ data: { colonyId, userId: recorderId, date: d('2026-05-04') }, }); }); afterEach(cleanDb); afterAll(async () => { await cleanDb(); await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.colony.delete({ where: { id: colonyId } }); await prisma.apiary.delete({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); it('returns the three steps of the spec example in order', async () => { await prisma.suggestion.createMany({ data: exampleSuggestions() }); const view = await nextSteps.forColony( colonyId, users.full.id, EXAMPLE_DAY, ); expect(view).toMatchObject({ colonyId, segment: 'SCHWARMZEIT', phase: 'APFEL', phaseSource: 'CALENDAR', inspectionDueOn: '2026-05-11', withdrawalUntil: null, }); expect(view.steps).toHaveLength(3); const [inspection, treatment, honey] = view.steps; expect(inspection).toMatchObject({ title: 'Durchsicht mit Schwarmkontrolle', urgency: 'OVERDUE', dueOn: '2026-05-11', action: { type: 'CAPTURE', form: 'inspection', href: `/inspections/new?colonyId=${colonyId}`, }, dismissible: true, }); expect(inspection.reasons).toHaveLength(2); expect(inspection.reasons[0]).toContain('Weiselzellen'); expect(inspection.reasons[1]).toContain('Letzte Durchsicht vor 8 Tagen'); expect(treatment).toMatchObject({ intent: 'VARROA_TREAT', action: { type: 'ACCEPT' }, dismissible: true, }); expect(treatment.reasons.join(' ')).toContain('12,4'); expect(honey).toMatchObject({ intent: 'HONEY_SUPER', title: 'Honigraum aufsetzen', urgency: 'SOON', action: { type: 'CAPTURE', form: 'inspection' }, }); }); it('a HONEY_SUPER PLAN suggestion stays a step of its own next to the merged inspection', async () => { await prisma.suggestion.createMany({ data: [ ...exampleSuggestions().filter((row) => row.intent !== 'HONEY_SUPER'), suggestion({ ruleKey: 'SEASON_AUTOPILOT', source: 'AUTOPILOT', mode: 'PLAN', intent: 'HONEY_SUPER', reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER', dueDate: d('2026-05-14'), }), ], }); const view = await nextSteps.forColony( colonyId, users.full.id, EXAMPLE_DAY, ); const titles = view.steps.map((step) => step.title); expect(titles).toContain('Durchsicht mit Schwarmkontrolle'); expect(view.steps.map((step) => step.intent)).toContain('HONEY_SUPER'); const merged = view.steps.find((step) => step.title.startsWith('Durchsicht'), ); expect(merged?.suggestionIds).toHaveLength(2); expect(merged?.dismissible).toBe(true); }); it('next steps never carry a varroa reason without varroa:read', async () => { await prisma.suggestion.createMany({ data: exampleSuggestions() }); const full = await get( users.full.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const restricted = await get( users.noVarroa.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const fullBody = full.body as NextStepsBody; const restrictedBody = restricted.body as NextStepsBody; expect(fullBody.steps.map((step) => step.intent)).toContain('VARROA_TREAT'); expect(restrictedBody.steps.map((step) => step.intent)).not.toContain( 'VARROA_TREAT', ); expect(JSON.stringify(restrictedBody)).not.toMatch(/Milben|Behandlung/); }); it('a user without tasks:read gets no suggestion steps and no ACCEPT action', async () => { await prisma.suggestion.createMany({ data: exampleSuggestions() }); await prisma.task.create({ data: { title: 'Wachs einschmelzen', relatedType: 'COLONY', relatedId: colonyId, }, }); const noTasks = await get( users.noTasks.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const noCreate = await get( users.noCreate.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const hidden = noTasks.body as NextStepsBody; expect(hidden.steps).toEqual([]); // The inspection date is not a task thing: it stays. expect(hidden.segment).toEqual(expect.any(String)); const limited = noCreate.body as NextStepsBody; expect(limited.steps.length).toBeGreaterThan(0); expect(limited.steps.map((step) => step.action.type)).not.toContain( 'ACCEPT', ); expect( limited.steps.find((step) => step.intent === 'VARROA_TREAT')?.action, ).toMatchObject({ type: 'CAPTURE', form: 'treatment' }); }); it('withdrawalUntil appears as a hint, not as a step', async () => { const today = berlinToday(); await prisma.treatment.create({ data: { colonyId, userId: recorderId, date: d(addDays(today, -3)), product: 'Oxalsäure', withdrawalPeriodDays: 30, }, }); const full = await get( users.full.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const readerWithoutTasks = await get( users.noTasks.token, `/colonies/${colonyId}/next-steps`, ).expect(200); const body = full.body as NextStepsBody; expect(body.withdrawalUntil).toBe(addDays(today, 27)); expect(body.steps).toEqual([]); expect((readerWithoutTasks.body as NextStepsBody).withdrawalUntil).toBe( addDays(today, 27), ); }); it('answers 404 for an unknown colony and 403 without colonies:read', async () => { await get(users.full.token, '/colonies/does-not-exist/next-steps').expect( 404, ); const noColonies = await createUser('nocolonies', ['tasks:read']); await get(noColonies.token, `/colonies/${colonyId}/next-steps`).expect(403); await get(noColonies.token, '/autopilot/today').expect(403); }); it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', async () => { await prisma.suggestion.createMany({ data: exampleSuggestions() }); const full = await nextSteps.today(users.full.id, EXAMPLE_DAY); const entry = full.inspectionDue.find((item) => item.apiaryId === apiaryId); expect(entry).toMatchObject({ apiaryName: `Next steps ${suffix}`, segment: 'SCHWARMZEIT', intervalDays: 7, colonies: [ { id: colonyId, code: `B3-${suffix}`, dueOn: '2026-05-11', overdueDays: 1, }, ], }); expect(full.seasons).toContainEqual( expect.objectContaining({ apiaryId, segment: 'SCHWARMZEIT' }), ); expect(full.suggestionsOpen).toBeGreaterThanOrEqual(4); expect(full.tasksVisible).toBe(true); const noTasks = await get(users.noTasks.token, '/autopilot/today').expect( 200, ); const hidden = noTasks.body as TodayBody; expect(hidden).toMatchObject({ nextStep: null, suggestionsOpen: 0, tasksVisible: false, }); expect(hidden.seasons.map((season) => season.apiaryId)).toContain(apiaryId); const allowed = await get(users.full.token, '/autopilot/today').expect(200); const body = allowed.body as TodayBody; expect(body.tasksVisible).toBe(true); expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4); expect(body.nextStep).not.toBeNull(); }); });