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 BandBody { yellowFrom: number | null; redAbove: number; reason: string; youngColony: boolean; } interface ThresholdsBody { day: string; active: boolean; bands: Record; postTreatmentUntil: string | null; } interface RatedMeasurementBody { id: string; rating: { light: string | null; reasonCode: string | null; band: BandBody | null; postTreatmentUntil: string | null; }; } describe('Varroa seasonal bands (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = Date.now(); const email = `varroa-seasonal-${suffix}@example.com`; const password = 'SuperSecret123!'; let userId: string; let token: string; let apiaryId: string; let productionColonyId: string; let youngColonyId: string; // Colonies of the rule cases: one per case, so no open suggestion of an // earlier case keeps a later one from appearing. const ruleColonyIds: string[] = []; const get = (path: string) => request(app.getHttpServer()) .get(`/api/v1${path}`) .set('Authorization', `Bearer ${token}`); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: 'Varroa seasonal', role: { connect: { key: 'BEEKEEPER' } }, }, }); userId = user.id; token = (await login(app, email, password)).accessToken; apiaryId = ( await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }) ).id; productionColonyId = ( await prisma.colony.create({ data: { code: `VS-P-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; youngColonyId = ( await prisma.colony.create({ data: { code: `VS-Y-${suffix}`, apiaryId, colonyType: 'YOUNG_COLONY' }, }) ).id; }); afterAll(async () => { const colonyIds = [productionColonyId, youngColonyId, ...ruleColonyIds]; const measurementIds = ( await prisma.varroaMeasurement.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, }) ).map((measurement) => measurement.id); const suggestionIds = ( await prisma.suggestion.findMany({ where: { colonyId: { in: colonyIds } }, select: { id: true }, }) ).map((suggestion) => suggestion.id); await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, }); await prisma.auditLog.deleteMany({ where: { OR: [ { entityType: 'Suggestion', entityId: { in: suggestionIds } }, ...colonyIds.map((id) => ({ entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: id }, })), ], }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: measurementIds } }, }); await prisma.treatment.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.varroaMeasurement.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 } }); await prisma.auditLog.deleteMany({ where: { userId } }); await prisma.user.deleteMany({ where: { id: userId } }); await app.close(); }); it('thresholds for a young colony are halved', async () => { const full = ( await get( `/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-06-10`, ).expect(200) ).body as ThresholdsBody; const young = ( await get( `/varroa-measurements/thresholds?colonyId=${youngColonyId}&date=2026-06-10`, ).expect(200) ).body as ThresholdsBody; expect(full.bands.NATURAL_MITE_FALL).toMatchObject({ yellowFrom: 5, redAbove: 10, reason: 'NATURAL_FALL_SEASON', youngColony: false, }); expect(young.bands.NATURAL_MITE_FALL).toMatchObject({ yellowFrom: 2.5, redAbove: 5, youngColony: true, }); }); it('thresholds return no band for powdered sugar in April', async () => { const body = ( await get( `/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-04-10`, ).expect(200) ).body as ThresholdsBody; expect(body.bands.POWDERED_SUGAR).toBeUndefined(); expect(body.bands.NATURAL_MITE_FALL).toBeDefined(); }); it('thresholds reject an unknown colony and an invalid date', async () => { await get('/varroa-measurements/thresholds?colonyId=unknown').expect(404); await get('/varroa-measurements/thresholds?date=2026-02-30').expect(400); await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400); }); describe('range form', () => { interface SegmentsBody { segments: { from: string; to: string; bands: Record; postTreatmentUntil: string | null; active: boolean; }[]; } const range = (query: string) => get(`/varroa-measurements/thresholds?${query}`); it('returns merged segments split at the season edges', async () => { const body = ( await range( `colonyId=${productionColonyId}&from=2026-10-30&to=2026-11-02`, ).expect(200) ).body as SegmentsBody; expect(body.segments.map((s) => [s.from, s.to, s.active])).toEqual([ ['2026-10-30', '2026-10-31', true], ['2026-11-01', '2026-11-02', true], ]); expect(body.segments[0].bands.NATURAL_MITE_FALL).toMatchObject({ reason: 'NATURAL_FALL_SEASON', }); expect(body.segments[1].bands.NATURAL_MITE_FALL).toMatchObject({ reason: 'NATURAL_FALL_WINTER', }); }); it('accepts 731 days and rejects 732', async () => { await range('from=2025-01-01&to=2027-01-01').expect(200); await range('from=2025-01-01&to=2027-01-02').expect(400); }); it('rejects from after to, a missing bound, a mix with date and a bad day', async () => { await range('from=2026-06-02&to=2026-06-01').expect(400); await range('from=2026-06-02').expect(400); await range('to=2026-06-02').expect(400); await range('date=2026-06-01&from=2026-06-01&to=2026-06-02').expect(400); await range('from=2026-02-30&to=2026-03-05').expect(400); await range('colonyId=unknown&from=2026-06-01&to=2026-06-02').expect(404); }); }); it('the old /threshold route is gone (404)', async () => { await get('/varroa-measurements/threshold').expect(404); }); it('colony measurements carry a rating with band and light', async () => { const create = (body: object) => request(app.getHttpServer()) .post('/api/v1/varroa-measurements') .set('Authorization', `Bearer ${token}`) .send(body) .expect(201); const red = ( await create({ colonyId: productionColonyId, date: '2026-08-10T00:00:00.000Z', method: 'NATURAL_MITE_FALL', miteCount: 22, durationMinutes: 1440, }) ).body as { id: string; calculatedValue: number }; const percent = ( await create({ colonyId: productionColonyId, date: '2026-07-10T00:00:00.000Z', method: 'POWDERED_SUGAR', miteCount: 10, sampleBees: 500, }) ).body as { id: string; calculatedValue: number }; expect(percent.calculatedValue).toBe(2); const rated = ( await get(`/colonies/${productionColonyId}/varroa-measurements`).expect( 200, ) ).body as RatedMeasurementBody[]; const byId = new Map(rated.map((entry) => [entry.id, entry.rating])); expect(byId.get(red.id)).toMatchObject({ light: 'red', reasonCode: 'NATURAL_FALL_SEASON', band: { yellowFrom: 5, redAbove: 10 }, postTreatmentUntil: null, }); expect(byId.get(percent.id)).toMatchObject({ light: 'yellow', reasonCode: 'INFESTATION_SUMMER', }); }); it('a measurement after a treatment has no light and names the recheck day', async () => { await prisma.treatment.create({ data: { colonyId: youngColonyId, userId, date: new Date('2026-08-02T00:00:00.000Z'), startDate: new Date('2026-07-20T00:00:00.000Z'), product: 'Oxuvar', }, }); const created = ( await request(app.getHttpServer()) .post('/api/v1/varroa-measurements') .set('Authorization', `Bearer ${token}`) .send({ colonyId: youngColonyId, date: '2026-08-07T00:00:00.000Z', method: 'NATURAL_MITE_FALL', calculatedValue: 12, }) .expect(201) ).body as { id: string }; const rated = ( await get(`/colonies/${youngColonyId}/varroa-measurements`).expect(200) ).body as RatedMeasurementBody[]; expect(rated.find((entry) => entry.id === created.id)?.rating).toEqual({ light: 'POST_TREATMENT', reasonCode: 'POST_TREATMENT', band: null, postTreatmentUntil: '2026-08-16', }); }); describe('the varroa rule', () => { const newColony = async (): Promise => { const colony = await prisma.colony.create({ data: { code: `VS-R-${suffix}-${ruleColonyIds.length}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); ruleColonyIds.push(colony.id); return colony.id; }; /** Records a measurement and runs the handlers of its event. */ const measure = async ( colonyId: string, date: string, miteCount: number, ): Promise => { const created = ( await request(app.getHttpServer()) .post('/api/v1/varroa-measurements') .set('Authorization', `Bearer ${token}`) .send({ colonyId, date, method: 'NATURAL_MITE_FALL', miteCount, // One day on the board: the count is the rate per day. durationMinutes: 24 * 60, }) .expect(201) ).body as { id: string }; await drainDomainEvents(app, [created.id]); return created.id; }; const ruleSuggestions = (colonyId: string) => prisma.suggestion.findMany({ where: { colonyId, ruleKey: 'VARROA_THRESHOLD' }, }); it('12 mites/day in August proposes a treatment check', async () => { const colonyId = await newColony(); const id = await measure(colonyId, '2026-08-10T00:00:00.000Z', 12); const suggestions = await ruleSuggestions(colonyId); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ intent: 'VARROA_TREAT', reasonCode: 'VARROA_RED', status: 'OPEN', dedupeKey: `VARROA_THRESHOLD:${colonyId}:${id}`, evidence: { value: 12, redAbove: 10, unit: 'MITES_PER_DAY' }, }); }); it('12 mites/day five days after treatment end proposes nothing', async () => { const colonyId = await newColony(); await prisma.treatment.create({ data: { colonyId, userId, date: new Date('2026-08-01T00:00:00.000Z'), endDate: new Date('2026-08-05T00:00:00.000Z'), product: 'Oxuvar', }, }); await measure(colonyId, '2026-08-10T00:00:00.000Z', 12); expect(await ruleSuggestions(colonyId)).toHaveLength(0); }); it('10.0 mites/day in August proposes nothing (yellow)', async () => { const colonyId = await newColony(); await measure(colonyId, '2026-08-10T00:00:00.000Z', 10); expect(await ruleSuggestions(colonyId)).toHaveLength(0); }); }); });