- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
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<string, BandBody | undefined>;
|
|
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<App>;
|
|
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<string, unknown>;
|
|
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<string> => {
|
|
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<string> => {
|
|
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);
|
|
});
|
|
});
|
|
});
|