feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- 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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

+152 -92
View File
@@ -50,6 +50,24 @@ describe('Automation rules (e2e)', () => {
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);
@@ -91,15 +109,15 @@ describe('Automation rules (e2e)', () => {
});
afterAll(async () => {
// Rule tasks 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 ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: colonyId },
// 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 suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
const byColony = { where: { colonyId }, select: { id: true } };
const recordIds = [
...(await prisma.inspection.findMany(byColony)),
@@ -108,7 +126,7 @@ describe('Automation rules (e2e)', () => {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Task', entityId: { in: ruleTaskIds } },
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
@@ -117,13 +135,15 @@ describe('Automation rules (e2e)', () => {
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } },
where: { aggregateId: { in: recordIds } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } },
where: { sourceId: { in: recordIds } },
});
await prisma.task.deleteMany({ where: { relatedId: colonyId } });
await prisma.notification.deleteMany({ where: { relatedId: colonyId } });
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
await prisma.inspection.deleteMany({ where: { colonyId } });
@@ -254,19 +274,32 @@ describe('Automation rules (e2e)', () => {
}
});
it('creates a task and notification when queen not seen and no eggs present', async () => {
await record('inspections', { queenSeen: false, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
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,
});
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('Kontrolle');
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: { relatedId: colonyId },
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
});
// One copy per user who may read tasks, never a broadcast.
// 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(
@@ -277,31 +310,28 @@ describe('Automation rules (e2e)', () => {
it('does not trigger when the queen was seen', async () => {
await record('inspections', { queenSeen: true, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(1);
expect(await suggestionsOf()).toHaveLength(1);
});
it('triggers a feeding task when feed status is kritisch', async () => {
it('proposes feeding when feed status is kritisch, without a notification', async () => {
await record('inspections', { feedStatus: 'CRITICAL' });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(2);
expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true);
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 before = await prisma.task.count({ where: { relatedId: colonyId } });
const id = await record('inspections', { queenSeen: true });
// Close the open tasks so the one-open-task guard cannot be what keeps
// a new task from appearing - only the missing evaluation may.
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await dismissOpen();
const before = await ruleSuggestionCount();
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${id}`)
@@ -310,57 +340,74 @@ describe('Automation rules (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [id]);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
expect(await ruleSuggestionCount()).toBe(before);
});
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
const before = await prisma.task.count({ where: { relatedId: colonyId } });
await record('varroa-measurements', {
it('proposes a treatment check when the mite fall is red', async () => {
const id = await record('varroa-measurements', {
method: 'NATURAL_MITE_FALL',
miteCount: 20,
// One day on the board: 20 mites per day, above the default 10.
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 tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
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(tasks).toHaveLength(before + 1);
expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true);
expect(await tasksOfColony()).toBe(0);
});
it('does not compare a sample-based count against the per-day threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
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',
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
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',
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
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 threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
// Five mites per day: below the threshold.
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 prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
expect(await ruleSuggestionCount()).toBe(before);
// The count was miscounted: 25 mites per day.
await request(app.getHttpServer())
@@ -370,16 +417,35 @@ describe('Automation rules (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [id]);
const tasks = await prisma.task.findMany({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'VARROA_THRESHOLD',
},
const open = await prisma.suggestion.findMany({
where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' },
});
expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before + 1,
);
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 });
});
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
@@ -430,25 +496,19 @@ describe('Automation rules (e2e)', () => {
swarmMood: 'HIGH',
})
.expect(201);
const swarmTasks = () =>
prisma.task.count({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'SWARM_MOOD_HIGH',
},
const swarmSuggestions = () =>
prisma.suggestion.count({
where: { colonyId, ruleKey: 'SWARM_MOOD_HIGH' },
});
// While active, high swarm mood leaves a task - the rule really fires.
// (Swarm checks stay on the post-commit path: evaluated before the
// response, no event to drain.)
// 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 swarmTasks()).toBe(1);
// Close it so the one-open-task guard cannot be what keeps a new task
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 prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await dismissOpen();
try {
await request(app.getHttpServer())
@@ -459,7 +519,7 @@ describe('Automation rules (e2e)', () => {
await highSwarmMood();
expect(await swarmTasks()).toBe(1);
expect(await swarmSuggestions()).toBe(1);
} finally {
await prisma.automationRule.update({
where: { id: swarmRuleId },
+409
View File
@@ -0,0 +1,409 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** Fixed day inside the swarm season by calendar; 2001 keeps it apart from other files. */
const TODAY = '2001-05-12';
/**
* The autopilot sync against a real PostgreSQL, on fixed days in 2001. The
* colonies are created 2001-04-20, so no other file's data is reached.
*/
describe('Autopilot (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let autopilot: AutopilotService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let userId: string;
const colonyIds: string[] = [];
const roleIds: string[] = [];
const userIds: string[] = [];
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
async function colonyWithLateInspection(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-${name}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const sync = (colonyId: string, day = TODAY) =>
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, colonyId, day));
const suggestionsOf = (colonyId: string, intent = 'INSPECT') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', intent: intent as 'INSPECT' },
orderBy: { createdAt: 'asc' },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
autopilot = app.get(AutopilotService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Autopilot e2e ${suffix}` } })
).id;
userId = (
await prisma.user.create({
data: {
email: `autopilot-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Autopilot e2e',
role: { connect: { key: 'STAFF' } },
},
})
).id;
});
afterAll(async () => {
const inspections = await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspections.map((i) => i.id) } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
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('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', async () => {
const colonyId = await colonyWithLateInspection('overdue');
const inspection = await prisma.inspection.findFirstOrThrow({
where: { colonyId },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'OPEN',
mode: 'DO',
priority: 'HIGH',
ruleKey: 'INSPECTION_INTERVAL',
reasonCode: 'INSPECTION_DUE',
requiredPermission: 'inspections:read',
relatedType: 'COLONY',
relatedId: colonyId,
dedupeKey: `INSPECTION_INTERVAL:${colonyId}:${inspection.id}`,
});
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-10');
expect(suggestion.expiresAt?.toISOString().slice(0, 10)).toBe('2001-06-11');
});
it('a second sync refreshes instead of creating another suggestion', async () => {
const colonyId = await colonyWithLateInspection('refresh');
await sync(colonyId);
const again = await sync(colonyId, '2001-05-13');
expect(again).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('after a new inspection the INSPECT suggestion is RESOLVED', async () => {
const colonyId = await colonyWithLateInspection('resolved');
await sync(colonyId);
await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 1 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'RESOLVED',
decisionNote: 'Bedingung entfällt',
dedupeKey: null,
});
});
it('two colony syncs in parallel leave one INSPECT suggestion', async () => {
const colonyId = await colonyWithLateInspection('parallel');
await Promise.all([sync(colonyId), sync(colonyId), sync(colonyId)]);
const open = (await suggestionsOf(colonyId)).filter(
(suggestion) => suggestion.status === 'OPEN',
);
expect(open).toHaveLength(1);
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await colonyWithLateInspection('returns');
await sync(colonyId);
const fresh = await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
await sync(colonyId);
await prisma.inspection.delete({ where: { id: fresh.id } });
const result = await sync(colonyId);
expect(result.opened).toBe(1);
expect(
(await suggestionsOf(colonyId)).map((suggestion) => suggestion.status),
).toEqual(['RESOLVED', 'OPEN']);
});
it('a dismissed suggestion keeps its anchor: the same condition is not proposed again', async () => {
const colonyId = await colonyWithLateInspection('dismissed');
await sync(colonyId);
await prisma.suggestion.updateMany({
where: { colonyId, intent: 'INSPECT' },
data: { status: 'DISMISSED' },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('an ended colony gets nothing', async () => {
const colonyId = await colonyWithLateInspection('ended');
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DECEASED' },
});
expect(await sync(colonyId)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(await suggestionsOf(colonyId)).toHaveLength(0);
});
it('proposes VARROA_MEASURE (A2) when the last measurement is older than 28 days', async () => {
const colonyId = await colonyWithLateInspection('varroa');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-04-07') },
});
await sync(colonyId);
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
ruleKey: 'SEASON_AUTOPILOT',
reasonCode: 'VARROA_MEASURE_DUE',
requiredPermission: 'varroa:read',
});
expect(measure.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-05');
});
it('autopilot.colony-sync resolves a stale autopilot suggestion when an inspection event is delivered', async () => {
// Real today: a colony inspected and measured today has no candidate.
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-event`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const inspection = await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: new Date() },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: new Date(),
method: 'NATURAL_MITE_FALL',
},
});
const stale = await prisma.suggestion.create({
data: {
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colony.id,
colonyId: colony.id,
reasonCode: 'INSPECTION_DUE',
evidence: {},
dedupeKey: `INSPECTION_INTERVAL:${colony.id}:stale`,
},
});
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId: colony.id },
}),
);
await drainDomainEvents(app, [inspection.id]);
const after = await prisma.suggestion.findUniqueOrThrow({
where: { id: stale.id },
});
expect(after.status).toBe('RESOLVED');
expect(after.decisionNote).toBe('Bedingung entfällt');
});
/** A colony with a treatment that ended 2001-04-28 and an older measurement. */
async function colonyAfterTreatment(
name: string,
measurements: string[],
): Promise<string> {
const colonyId = await colonyWithLateInspection(name);
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
for (const date of measurements) {
await prisma.varroaMeasurement.create({
data: { colonyId, userId, date: d(date), method: 'NATURAL_MITE_FALL' },
});
}
await prisma.treatment.create({
data: {
colonyId,
userId,
date: d('2001-04-14'),
endDate: d('2001-04-28'),
product: 'Oxuvar',
},
});
return colonyId;
}
it('A3: treatment ended 14 days ago -> VARROA_RECHECK for varroa readers, and no A2 beside it', async () => {
const colonyId = await colonyAfterTreatment('recheck', ['2001-04-01']);
await sync(colonyId);
const [recheck] = await suggestionsOf(colonyId, 'VARROA_RECHECK');
expect(recheck).toMatchObject({
status: 'OPEN',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
requiredPermission: 'varroa:read',
});
expect(recheck.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-12');
expect(await suggestionsOf(colonyId, 'VARROA_MEASURE')).toHaveLength(0);
});
it('A3: a measurement on the end day does not count, one the day after does', async () => {
const onEndDay = await colonyAfterTreatment('endday', ['2001-04-28']);
const dayAfter = await colonyAfterTreatment('dayafter', ['2001-04-29']);
await sync(onEndDay);
await sync(dayAfter);
expect(await suggestionsOf(onEndDay, 'VARROA_RECHECK')).toHaveLength(1);
expect(await suggestionsOf(dayAfter, 'VARROA_RECHECK')).toHaveLength(0);
});
it('A2 winter window: no measurement since 11-01 -> VARROA_WINTER_MEASURE with anchor winter-2001', async () => {
const colonyId = await colonyWithLateInspection('winter');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-09-01') },
});
await sync(colonyId, '2001-11-20');
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
reasonCode: 'VARROA_WINTER_MEASURE',
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:winter-2001`,
});
expect(await suggestionsOf(colonyId, 'INSPECT')).toHaveLength(0);
});
it('a user without varroa:read sees neither the A2 nor the A3 suggestion', async () => {
const password = 'SuperSecret123!';
const tokenFor = async (label: string, keys: string[]) => {
const role = await prisma.role.create({
data: {
key: `AP_E2E_${label.toUpperCase()}_${suffix}`,
name: `Autopilot e2e ${label}`,
permissions: {
create: keys.map((key) => ({ permission: { connect: { key } } })),
},
},
});
roleIds.push(role.id);
const email = `autopilot-e2e-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Autopilot e2e ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
};
const full = await tokenFor('full', ['tasks:read', 'varroa:read']);
const noVarroa = await tokenFor('novarroa', ['tasks:read']);
const colonyId = await colonyAfterTreatment('visible', ['2001-04-01']);
await sync(colonyId);
const list = (token: string) =>
request(app.getHttpServer())
.get(`/api/v1/suggestions?colonyId=${colonyId}`)
.set('Authorization', `Bearer ${token}`);
const seen = await list(full);
const hidden = await list(noVarroa);
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
expect(hidden.body).toEqual([]);
});
});
+365
View File
@@ -0,0 +1,365 @@
import { INestApplication } from '@nestjs/common';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers';
import { SuggestionsService } from '../src/suggestions/suggestions.service';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The biotechnical chains against a real PostgreSQL: an inspection, a queen
* assignment or a split leaves one PLAN suggestion; later inspections clean
* up the checks they answer. The events are recorded directly (the services
* that record them have their own e2e tests) and drained like the worker.
*/
describe('Chains (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let events: DomainEventsService;
let suggestions: SuggestionsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let colonyId: string;
let userId: string;
let aggregateIds: string[] = [];
// Only the chains: the season autopilot writes its own suggestions for the
// same colony when the inspection event is delivered.
const chainSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN' },
orderBy: { createdAt: 'asc' },
});
const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN', status: 'OPEN', intent },
});
const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10);
/** Records an inspection and runs the handlers of its event. */
async function inspect(
day: string,
findings: {
queenCellsPresent?: boolean | null;
queenSeen?: boolean | null;
eggsPresent?: boolean | null;
} = {},
): Promise<string> {
const inspection = await prisma.inspection.create({
data: {
colonyId,
userId,
date: new Date(`${day}T10:00:00.000Z`),
queenCellsPresent: findings.queenCellsPresent ?? null,
queenSeen: findings.queenSeen ?? null,
eggsPresent: findings.eggsPresent ?? null,
},
});
await recordAndDrain(inspection.id, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId },
}),
),
);
return inspection.id;
}
async function assignQueen(assignedOn: string): Promise<string> {
const queenId = `queen-${suffix}-${aggregateIds.length}`;
const historyId = `hist-${suffix}-${aggregateIds.length}`;
await recordAndDrain(queenId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queenId,
payload: { colonyId, historyId, assignedOn, previousColonyId: null },
}),
),
);
return historyId;
}
async function split(
queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null,
): Promise<void> {
await recordAndDrain(colonyId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: {
parentColonyId: `parent-${suffix}`,
lineageId: `lineage-${suffix}-${aggregateIds.length}`,
queenSource,
splitOn: '2026-05-01',
},
}),
),
);
}
async function recordAndDrain(
aggregateId: string,
record: () => Promise<void>,
): Promise<void> {
aggregateIds = [...aggregateIds, aggregateId];
await record();
await drainDomainEvents(app, [aggregateId]);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
events = app.get(DomainEventsService);
suggestions = app.get(SuggestionsService);
const apiary = await prisma.apiary.create({
data: { name: `Chains e2e ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `CHN-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const user = await prisma.user.create({
data: {
email: `chains-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Chains e2e',
role: { connect: { key: 'STAFF' } },
},
});
userId = user.id;
});
async function cleanUp(): Promise<void> {
await prisma.task.deleteMany({
where: {
OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
aggregateIds = [];
}
afterEach(async () => {
await cleanUp();
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'ACTIVE' },
});
});
afterAll(async () => {
await cleanUp();
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{
entityType: 'Suggestion',
afterData: { path: ['colonyId'], equals: colonyId },
},
],
},
});
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.user.delete({ where: { id: userId } });
await app.close();
});
it('queen cells -> one PLAN suggestion due +7', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
const open = await openOf('SWARM_CHECK');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
source: 'CHAIN',
mode: 'PLAN',
relatedType: 'COLONY',
relatedId: colonyId,
requiredPermission: 'inspections:read',
dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0);
});
it('an inspection without queen cells proposes nothing', async () => {
await inspect('2026-05-05', { queenCellsPresent: false });
await inspect('2026-05-06');
expect(await chainSuggestions()).toHaveLength(0);
});
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => {
await split('RAISE_OWN');
const [suggestion] = await openOf('SPLIT_PLAN');
expect(suggestion).toMatchObject({
ruleKey: 'SPLIT_SCHEDULE',
mode: 'PLAN',
requiredPermission: 'colonies:read',
});
expect(dayOf(suggestion.dueDate)).toBe('2026-05-10');
const result = await suggestions.accept(suggestion.id, {}, userId);
expect(
result.tasks.map((task) => [task.title, dayOf(task.dueDate)]),
).toEqual([
['Nachschaffungszellen prüfen', '2026-05-10'],
['Begattung prüfen', '2026-05-22'],
['Stifte prüfen', '2026-05-29'],
]);
});
it('a split without queenSource leaves nothing', async () => {
await split(null);
expect(await chainSuggestions()).toHaveLength(0);
});
it('a queen assignment -> acceptance check +7', async () => {
const historyId = await assignQueen('2026-05-05');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
source: 'CHAIN',
mode: 'PLAN',
requiredPermission: 'queens:read',
dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
});
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => {
await split('QUEEN');
await assignQueen('2026-05-01');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE');
expect(dayOf(open[0].dueDate)).toBe('2026-05-08');
});
it('an inspection on the due day resolves the open swarm check', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-12', { queenCellsPresent: false });
const [resolved] = await chainSuggestions();
expect(resolved).toMatchObject({
status: 'RESOLVED',
dedupeKey: null,
decisionNote: 'Mit Durchsicht erledigt',
});
});
it('an inspection before the due day leaves the swarm check open', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-08', { queenCellsPresent: false });
expect(await openOf('SWARM_CHECK')).toHaveLength(1);
});
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
const secondId = await inspect('2026-05-08', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']);
expect(all[1].dedupeKey).toBe(
`QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`,
);
expect(dayOf(all[1].dueDate)).toBe('2026-05-15');
});
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => {
await assignQueen('2026-05-05');
await inspect('2026-05-12');
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1);
await inspect('2026-05-13', { eggsPresent: true });
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0);
});
it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
await split('RAISE_OWN');
const handlers = app.get(ChainEventHandlers);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: { in: aggregateIds } },
});
expect(events).toHaveLength(2);
// Redelivery after a crash: the handlers run again with the stored events.
for (const event of events) {
await prisma.$transaction(async (tx) => {
const envelope = { ...event, attempts: 1 } as never;
if (event.type === 'InspectionRecorded') {
await handlers.queenCells(envelope, tx);
} else {
await handlers.splitSchedule(envelope, tx);
}
});
}
const all = await chainSuggestions();
expect(all.map((row) => [row.intent, row.status])).toEqual([
['SWARM_CHECK', 'OPEN'],
['SPLIT_PLAN', 'OPEN'],
]);
expect(all[0].dedupeKey).toContain(inspectionId);
});
it('a backdated inspection with queen cells does not replace the newer open check', async () => {
await inspect('2026-05-08', { queenCellsPresent: true });
await inspect('2026-05-02', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['OPEN']);
expect(dayOf(all[0].dueDate)).toBe('2026-05-15');
});
it('nothing for an ended colony', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DISSOLVED' },
});
await inspect('2026-05-05', { queenCellsPresent: true });
await assignQueen('2026-05-05');
await split('QUEEN');
expect(await chainSuggestions()).toHaveLength(0);
});
});
+29 -19
View File
@@ -86,28 +86,30 @@ describe('Colonies (e2e)', () => {
raceColonyId,
...splitColonyIds,
].filter(Boolean);
// Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below.
const ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
// Rule suggestions are written without a user - their audit entry and
// warning are not covered by the userId cleanup below.
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: ruleTaskIds } },
where: { entityType: 'Suggestion', entityId: { in: suggestionIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.notification.deleteMany({
where: {
OR: [
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
{ aggregateType: 'Task', aggregateId: { in: ruleTaskIds } },
{ relatedId: { in: colonyIds } },
{ relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
],
},
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await prisma.notification.deleteMany({
where: { relatedId: { in: colonyIds } },
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.colonyLineage.deleteMany({
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
@@ -330,7 +332,7 @@ describe('Colonies (e2e)', () => {
expect(colony.status).toBe('WEAK');
});
it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => {
it('proposes the COLONY_HEALTH_CRITICAL check only after the event is processed, and only on the transition', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -356,15 +358,19 @@ describe('Colonies (e2e)', () => {
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
});
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(0);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
// Already critical: a further edit is no transition and opens no second task.
// Already critical: a further edit is no transition and proposes nothing a second time.
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -372,11 +378,13 @@ describe('Colonies (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => {
it('two concurrent PATCHes into CRITICAL produce one transition event and one suggestion', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -405,7 +413,9 @@ describe('Colonies (e2e)', () => {
).toBe(1);
await drainDomainEvents(app, [raceColonyId]);
expect(
await prisma.task.count({ where: { relatedId: raceColonyId } }),
await prisma.suggestion.count({
where: { colonyId: raceColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
});
@@ -0,0 +1,170 @@
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';
interface ImportReportBody {
created: number;
}
describe('Colony strength in occupied frames (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
let adminToken: string;
let adminId: string;
let apiaryId: string;
let apiaryName: string;
let colonyId: string;
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const admin = await prisma.user.create({
data: {
email: `strength-admin-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
adminToken = (await login(app, admin.email, password)).accessToken;
apiaryName = `Strength ${suffix}`;
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
colonyId = (
await prisma.colony.create({
data: {
code: `ST-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
strength: 7,
},
})
).id;
});
afterAll(async () => {
const colonies = await prisma.colony.findMany({
where: { apiaryId },
select: { id: true },
});
const ids = colonies.map((row) => row.id);
const inspections = await prisma.inspection.findMany({
where: { colonyId: { in: ids } },
select: { id: true },
});
const inspectionIds = inspections.map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateType: 'Inspection', aggregateId: { in: inspectionIds } },
{ aggregateType: 'Colony', aggregateId: { in: ids } },
],
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId: { in: ids } } });
await prisma.colony.deleteMany({ where: { id: { in: ids } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: adminId } });
await prisma.auditLog.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await app.close();
});
it('PATCH occupiedFrames keeps the legacy strength', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: 16 })
.expect(200);
expect(
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
).toMatchObject({ occupiedFrames: 16, strength: 7 });
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: null })
.expect(200);
expect(
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
.occupiedFrames,
).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: 41 })
.expect(400);
});
it('a takeover from an inspection writes only occupiedFrames', async () => {
await request(app.getHttpServer())
.post('/api/v1/inspections')
.set(auth(adminToken))
.send({
colonyId,
date: '2026-06-01T10:00:00.000Z',
colonyStrength: 12,
applyToColony: ['occupiedFrames'],
})
.expect(201);
expect(
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
).toMatchObject({
occupiedFrames: 12,
strength: 7,
broodStatus: null,
feedStatus: null,
});
});
it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set(auth(adminToken))
.send({ colonyId, date: '2026-06-02T10:00:00.000Z', colonyStrength: 41 })
.expect(400);
expect(JSON.stringify(response.body)).toContain(
'Höchstens 40 besetzte Waben.',
);
});
it('CSV export and import carry strength and occupiedFrames', async () => {
const exported = await request(app.getHttpServer())
.get('/api/v1/export/colonies.csv')
.set(auth(adminToken))
.expect(200);
const lines = exported.text.split('\n');
expect(lines[0].trim().endsWith(',occupiedFrames')).toBe(true);
expect(lines[0]).toContain('strength,healthStatus');
const row = lines.find((line) => line.includes(`ST-${suffix}`)) ?? '';
expect(row).toContain(',7,');
expect(row.trim().endsWith(',12')).toBe(true);
const code = `ST-IMP-${suffix}`;
const csv = `code,apiaryName,colonyType,strength,occupiedFrames\n${code},${apiaryName},PRODUCTION_COLONY,5,20`;
const response = await request(app.getHttpServer())
.post('/api/v1/import/colonies')
.set(auth(adminToken))
.attach('file', Buffer.from(csv), {
filename: 'colonies.csv',
contentType: 'text/csv',
})
.expect(201);
expect((response.body as ImportReportBody).created).toBe(1);
expect(
await prisma.colony.findUniqueOrThrow({ where: { code } }),
).toMatchObject({ strength: 5, occupiedFrames: 20 });
});
});
@@ -1,6 +1,9 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
@@ -27,7 +30,11 @@ describe('daily-evaluation (e2e)', () => {
let app: INestApplicationContext;
let prisma: PrismaService;
let step: TimeEventsStep;
let autopilotStep: AutopilotStep;
let expiryStep: SuggestionsExpiryStep;
const ids: Record<string, string> = {};
/** Colonies of the autopilot tests; the steps only see these. */
const autopilotColonyIds: string[] = [];
const eventsOf = (aggregateId: string) =>
prisma.domainEvent.findMany({
@@ -49,6 +56,25 @@ describe('daily-evaluation (e2e)', () => {
app = await moduleRef.init();
prisma = app.get(PrismaService);
step = app.get(TimeEventsStep);
expiryStep = app.get(SuggestionsExpiryStep);
// The real AutopilotStep, but on the colonies of this file only: other
// files share the database and must not be reconciled on a 2001 day.
autopilotStep = new AutopilotStep(
{
colony: {
findMany: (args: Prisma.ColonyFindManyArgs) =>
prisma.colony.findMany({
...args,
where: {
AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }],
},
}),
},
automationRule: prisma.automationRule,
$transaction: prisma.$transaction.bind(prisma) as unknown,
} as unknown as PrismaService,
app.get(AutopilotService),
);
const user = await prisma.user.create({
data: {
@@ -155,7 +181,15 @@ describe('daily-evaluation (e2e)', () => {
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
const colonyIds = [ids.living, ids.dead].filter(Boolean);
const colonyIds = [ids.living, ids.dead, ...autopilotColonyIds].filter(
Boolean,
);
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -305,4 +339,128 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['queen:young'])).toEqual([]);
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
});
describe('autopilot and suggestions-expiry steps', () => {
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
async function lateColony(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `DE-E2E-${suffix}-ap-${name}`,
apiaryId: ids.apiary,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
autopilotColonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId: ids.user,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const inspectSuggestions = (colonyId: string) =>
prisma.suggestion.findMany({
where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' },
orderBy: { createdAt: 'asc' },
});
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
const colonyId = await lateColony('twice');
const first = await autopilotStep.run('2001-05-12');
const firstExpiry = await expiryStep.run('2001-05-12');
const second = await autopilotStep.run('2001-05-12');
const secondExpiry = await expiryStep.run('2001-05-12');
expect(first).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 1,
suggestionsResolved: 0,
});
expect(second).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 0,
suggestionsResolved: 0,
});
expect(firstExpiry).toEqual({ suggestionsExpired: 0 });
expect(secondExpiry).toEqual({ suggestionsExpired: 0 });
expect(await inspectSuggestions(colonyId)).toHaveLength(1);
});
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
const colonyId = await lateColony('expires');
await autopilotStep.run('2001-05-12');
// expiresAt = 2001-06-11
expect(await expiryStep.run('2001-06-11')).toEqual({
suggestionsExpired: 0,
});
expect(await expiryStep.run('2001-06-12')).toEqual({
suggestionsExpired: expect.any(Number) as number,
});
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null });
});
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
const colonyId = await lateColony('again');
await autopilotStep.run('2001-05-12');
await expiryStep.run('2001-06-12');
const result = await autopilotStep.run('2001-06-12');
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['EXPIRED', 'OPEN'],
);
});
it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => {
const colonyId = await lateColony('overdue20');
await prisma.inspection.updateMany({
where: { colonyId },
data: { date: d('2001-04-10') },
});
await step.run('2001-05-12');
await autopilotStep.run('2001-05-12');
// Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up.
expect(
await prisma.domainEvent.count({
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
}),
).toBe(0);
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17');
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await lateColony('returns');
await autopilotStep.run('2001-05-12');
const fresh = await prisma.inspection.create({
data: { colonyId, userId: ids.user, date: d('2001-05-12') },
});
const resolved = await autopilotStep.run('2001-05-12');
await prisma.inspection.delete({ where: { id: fresh.id } });
const returned = await autopilotStep.run('2001-05-12');
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['RESOLVED', 'OPEN'],
);
});
});
});
@@ -136,11 +136,8 @@ describe('Data integrity (e2e)', () => {
await drainDomainEvents(app, [(first.body as { id: string }).id]);
// The automation fired once, not once per delivery attempt.
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: { not: null },
},
await prisma.suggestion.count({
where: { colonyId: colony.id, source: 'RULE' },
}),
).toBe(1);
});
@@ -261,7 +258,7 @@ describe('Data integrity (e2e)', () => {
expect(after.quantity).toBe(5);
});
it('creates only one open reorder task while the item stays below minimum', async () => {
it('proposes only one open reorder while the item stays below minimum', async () => {
const item = await prisma.stockItem.create({
data: {
name: `Deckel ${suffix}`,
@@ -279,11 +276,11 @@ describe('Data integrity (e2e)', () => {
.expect(201);
}
expect(
await prisma.task.count({
await prisma.suggestion.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
ruleKey: 'STOCK_ITEM_LOW',
status: 'OPEN',
},
}),
).toBe(1);
@@ -92,6 +92,7 @@ describe('Feedings (e2e)', () => {
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
});
await prisma.feeding.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -254,6 +255,9 @@ describe('Feedings (e2e)', () => {
await prisma.feeding.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
});
@@ -95,16 +95,30 @@ describe('Follow-up controls (e2e)', () => {
},
data: { active: false, config: {} },
});
// Follow-up tasks are written without a user: remove their audit entry
// and TaskCreated (deliveries go with the event, ON DELETE CASCADE).
const followUpTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
// 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((task) => task.id);
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: followUpTaskIds } },
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({
@@ -122,9 +136,6 @@ describe('Follow-up controls (e2e)', () => {
await prisma.auditLog.deleteMany({
where: { entityId: { in: measureIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } },
});
await app.close();
});
@@ -136,9 +147,9 @@ describe('Follow-up controls (e2e)', () => {
const colony = await newColony('OFF');
await treat(colony.id);
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
});
it('suggests a check due the configured days after the treatment ended', async () => {
@@ -155,25 +166,30 @@ describe('Follow-up controls (e2e)', () => {
endDate: '2026-08-10T00:00:00.000Z',
});
const tasks = await prisma.task.findMany({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
const suggestions = await prisma.suggestion.findMany({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
});
expect(tasks).toHaveLength(1);
expect(tasks[0].dueDate?.toISOString()).toBe('2026-08-24T00:00:00.000Z');
expect(tasks[0].description).toContain('Testmittel');
expect(tasks[0].status).toBe('OPEN');
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.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
await prisma.suggestion.count({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
}),
).toBe(1);
});
@@ -199,17 +215,15 @@ describe('Follow-up controls (e2e)', () => {
})
.expect(201);
// Not before the FeedingRecorded handler ran.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
await drainDomainEvents(app, [(response.body as { id: string }).id]);
const task = await prisma.task.findFirstOrThrow({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'FEEDING_FOLLOW_UP',
},
const suggestion = await prisma.suggestion.findFirstOrThrow({
where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' },
});
expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' });
});
});
@@ -0,0 +1,272 @@
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';
interface TakeoverBody {
id: string;
colonyTakeover: {
applied: string[];
skipped: { field: string; reason: string }[];
};
}
describe('Inspection takeover (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
let staffToken: string;
let narrowToken: string;
let staffId: string;
let narrowId: string;
let narrowRoleId: string;
let apiaryId: string;
const colonyIds: string[] = [];
// One colony per test: no test depends on what another one wrote.
let colonyId: string;
const post = (token: string, body: object) =>
request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${token}`)
.send({ colonyId, ...body });
const colony = () =>
prisma.colony.findUniqueOrThrow({ where: { id: colonyId } });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: {
email: `takeover-staff-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
staffId = staff.id;
const narrowRole = await prisma.role.create({
data: {
key: `TAKEOVER_NARROW_${suffix}`,
name: 'Nur Durchsicht',
permissions: {
create: ['inspections:create', 'colonies:read'].map((key) => ({
permission: { connect: { key } },
})),
},
},
});
narrowRoleId = narrowRole.id;
const narrow = await prisma.user.create({
data: {
email: `takeover-narrow-${suffix}@example.com`,
passwordHash,
name: 'Narrow',
roleId: narrowRole.id,
},
});
narrowId = narrow.id;
staffToken = (await login(app, staff.email, password)).accessToken;
narrowToken = (await login(app, narrow.email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Takeover ${suffix}` } })
).id;
});
beforeEach(async () => {
const created = await prisma.colony.create({
data: {
code: `TK-${suffix}-${colonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
strength: 6,
},
});
colonyId = created.id;
colonyIds.push(created.id);
});
afterAll(async () => {
const ids = (
await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateType: 'Inspection', aggregateId: { in: ids } },
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
],
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: ids } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
const userIds = [staffId, narrowId];
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: narrowRoleId } });
await app.close();
});
it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', async () => {
const response = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
feedStatus: 'WEAK',
broodPattern: 4,
colonyStrength: 18,
applyToColony: ['feedStatus', 'broodStatus', 'occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.applied).toEqual([
'feedStatus',
'broodStatus',
'occupiedFrames',
]);
expect(body.colonyTakeover.skipped).toEqual([]);
expect(await colony()).toMatchObject({
feedStatus: 'WEAK',
broodStatus: 'GOOD',
occupiedFrames: 18,
strength: 6,
});
const audit = await prisma.auditLog.findFirstOrThrow({
where: {
entityType: 'Colony',
entityId: colonyId,
action: 'UPDATE',
userId: staffId,
},
});
expect(audit.beforeData).toMatchObject({
feedStatus: null,
broodStatus: null,
occupiedFrames: null,
});
expect(audit.afterData).toMatchObject({
feedStatus: 'WEAK',
broodStatus: 'GOOD',
occupiedFrames: 18,
});
});
it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', async () => {
await post(staffToken, { date: '2026-06-01T10:00:00.000Z' }).expect(201);
const response = await post(staffToken, {
date: '2026-05-01T10:00:00.000Z',
colonyStrength: 22,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.applied).toEqual([]);
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
expect((await colony()).occupiedFrames).toBeNull();
});
it('answers 201 and skips with NO_PERMISSION without colonies:update', async () => {
const response = await post(narrowToken, {
date: '2026-07-01T10:00:00.000Z',
colonyStrength: 25,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
]);
expect((await colony()).occupiedFrames).toBeNull();
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
});
it('skips with COLONY_ENDED for a dissolved colony and still saves the inspection', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
});
const response = await post(staffToken, {
date: '2026-07-01T10:00:00.000Z',
colonyStrength: 12,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
]);
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
expect((await colony()).occupiedFrames).toBeNull();
});
it('takes values only from the new inspection, never from an older stored row (even an out-of-range legacy value)', async () => {
await prisma.inspection.create({
data: {
colonyId,
date: new Date('2026-04-01T10:00:00.000Z'),
colonyStrength: 45,
userId: staffId,
},
});
const response = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
applyToColony: ['occupiedFrames'],
}).expect(201);
expect((response.body as TakeoverBody).colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_VALUE' },
]);
expect((await colony()).occupiedFrames).toBeNull();
});
it('a correction (PATCH) takes nothing over', async () => {
const created = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
}).expect(201);
const inspectionId = (created.body as TakeoverBody).id;
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${inspectionId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyStrength: 30 })
.expect(200);
expect((await colony()).occupiedFrames).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${inspectionId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ applyToColony: ['occupiedFrames'] })
.expect(400);
});
it.each([
[['feedStatus', 'broodStatus', 'occupiedFrames', 'feedStatus']],
[['feedStatus', 'feedStatus']],
[['nonsense']],
])('applyToColony %p answers 400', async (applyToColony) => {
await post(staffToken, {
date: '2026-08-01T10:00:00.000Z',
applyToColony,
}).expect(400);
});
});
+9 -2
View File
@@ -98,6 +98,7 @@ describe('Inspections (e2e)', () => {
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -159,7 +160,7 @@ describe('Inspections (e2e)', () => {
]);
});
it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => {
it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => {
await drainDomainEvents(app, [inspectionId]);
await request(app.getHttpServer())
@@ -188,11 +189,17 @@ describe('Inspections (e2e)', () => {
});
await drainDomainEvents(app, [inspectionId]);
// A correction evaluates no automation rule and sends no webhook.
// A correction evaluates no automation rule and sends no webhook; the
// calendar and the autopilot of the colony follow.
const deliveries = await prisma.domainEventDelivery.findMany({
where: { eventId: correction.id },
orderBy: { handler: 'asc' },
});
expect(deliveries).toEqual([
expect.objectContaining({
handler: 'autopilot.colony-sync',
status: 'DONE',
}),
expect.objectContaining({
handler: 'calendar-sync.inspection',
status: 'DONE',
@@ -150,6 +150,38 @@ describe('Notifications (e2e)', () => {
expect(listed).not.toContain(hidden.id);
});
it('a row the user may not see cannot be read, marked or counted', async () => {
const hidden = await seedNotification(ids.bare, {
requiredPermissions: ['colonies:read'],
});
const open = await seedNotification(ids.bare);
await request(server())
.post(`/api/v1/notifications/${hidden.id}/read`)
.set(as('bare'))
.expect(404);
const response = await request(server())
.post('/api/v1/notifications/read-all')
.set(as('bare'))
.expect(201);
expect(
(response.body as { updated: number }).updated,
).toBeGreaterThanOrEqual(1);
expect(
(await prisma.notification.findUniqueOrThrow({ where: { id: open.id } }))
.read,
).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: hidden.id },
})
).read,
).toBe(false);
});
it('reading is per user: marking one copy read leaves the other unread', async () => {
const [first, second] = await notifications.recordForUsers(
[ids.beekeeper, ids.staff],
@@ -55,20 +55,31 @@ describe('Post-commit side effects (e2e)', () => {
return colony;
}
/** Open or not: a rule task that exists at all. */
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: { sourceAutomationTrigger: trigger, relatedId },
/** Open or not: a rule suggestion that exists at all. */
const suggestionCount = (ruleKey: AutomationTriggerType, relatedId: string) =>
prisma.suggestion.count({ where: { ruleKey, relatedId } });
const openSuggestionCount = (
ruleKey: AutomationTriggerType,
relatedId: string,
) =>
prisma.suggestion.count({
where: { ruleKey, relatedId, status: 'OPEN' },
});
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: {
sourceAutomationTrigger: trigger,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
/** The notifications written for the suggestions of the given objects. */
async function suggestionNotifications(relatedIds: string[]) {
const ids = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
return prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
select: { userId: true },
});
}
/** Everything this file wrote, scoped by its own ids. */
async function cleanUp(): Promise<void> {
@@ -86,13 +97,13 @@ describe('Post-commit side effects (e2e)', () => {
.flat()
.map((record) => record.id);
const relatedIds = [...colonyIds, ...stockItemIds];
const taskIds = (
await prisma.task.findMany({
const suggestionIds = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((task) => task.id);
const aggregateIds = [...recordIds, ...taskIds];
).map((suggestion) => suggestion.id);
const aggregateIds = recordIds;
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
@@ -100,12 +111,12 @@ describe('Post-commit side effects (e2e)', () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: aggregateIds } },
});
// Rule tasks and the rules' own entries are written without a user.
// Rule suggestions and the rules' own entries are written without a user.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{ entityType: 'Task', entityId: { in: taskIds } },
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...relatedIds.map((relatedId) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: relatedId },
@@ -114,9 +125,11 @@ describe('Post-commit side effects (e2e)', () => {
},
});
await prisma.notification.deleteMany({
where: { relatedId: { in: relatedIds } },
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.inspection.deleteMany({ where: byColony.where });
await prisma.treatment.deleteMany({ where: byColony.where });
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
@@ -254,13 +267,11 @@ describe('Post-commit side effects (e2e)', () => {
attempts: 0,
}),
]);
// No rule ran in the request, not even one that failed: the task
// No rule ran in the request, not even one that failed: the suggestion
// follows when the handler runs.
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colony.id } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(await suggestionNotifications([colony.id])).toEqual([]);
});
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
@@ -286,7 +297,7 @@ describe('Post-commit side effects (e2e)', () => {
});
describe('when an automation handler fails', () => {
it('creates exactly one automation task when the handler fails once and is retried', async () => {
it('creates exactly one automation suggestion when the handler fails once and is retried', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
@@ -302,20 +313,16 @@ describe('Post-commit side effects (e2e)', () => {
await drainDomainEvents(app, [body.id]);
// The warning failed, so the whole evaluation rolled back.
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(await suggestionNotifications([colonyId])).toEqual([]);
// Due again now instead of after the 30 s backoff.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks and
// inspections), written once.
const warnings = await suggestionNotifications([colonyId]);
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
@@ -337,11 +344,11 @@ describe('Post-commit side effects (e2e)', () => {
});
});
describe('automation tasks', () => {
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
describe('automation suggestions', () => {
it('leaves one open reorder suggestion when several triggers for the same stock item arrive at once', async () => {
const automation = app.get(AutomationService);
// The same object crossing the threshold on several pods/requests at
// once: every evaluation sees "no open task yet" unless checking and
// once: every evaluation sees "nothing open yet" unless checking and
// creating are serialised.
const item = await prisma.stockItem.create({
data: {
@@ -358,10 +365,10 @@ describe('Post-commit side effects (e2e)', () => {
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
expect(await openSuggestionCount('STOCK_ITEM_LOW', item.id)).toBe(1);
});
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
it('leaves one open suggestion when several triggers for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE');
const measurement = {
colonyId: colony.id,
@@ -384,7 +391,7 @@ describe('Post-commit side effects (e2e)', () => {
),
);
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
expect(await openSuggestionCount('VARROA_THRESHOLD', colony.id)).toBe(1);
});
});
});
+60 -70
View File
@@ -59,8 +59,8 @@ describe('Raw materials and production (e2e)', () => {
let propolisId: string;
let productId: string;
let batchId: string;
// Tasks the RAW_MATERIAL_LOW handler created for this file.
const ruleTaskIds: string[] = [];
// Suggestions the RAW_MATERIAL_LOW handler created for this file.
const ruleSuggestionIds: string[] = [];
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
@@ -133,43 +133,35 @@ describe('Raw materials and production (e2e)', () => {
})
).map((movement) => movement.id);
const batchIds = [batchId].filter(Boolean);
// Rule tasks are written without a user: their TaskCreated, audit
// entries, warning and queued deliveries go by task id.
for (const taskId of ruleTaskIds) {
await prisma.auditLog.deleteMany({
where: {
entityType: 'AutomationRule',
afterData: { path: ['taskId'], equals: taskId },
},
});
await prisma.webhookDelivery.deleteMany({
where: { event: 'task.created', body: { contains: taskId } },
});
}
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: ruleTaskIds } },
// Rule suggestions are written without a user: their audit entries and
// warnings go by suggestion id.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } },
...ruleSuggestionIds.map((suggestionId) => ({
entityType: 'AutomationRule',
afterData: { path: ['suggestionId'], equals: suggestionId },
})),
],
},
});
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
// (default: off, no minimum) - so such a warning since this file
// started belongs to the task above.
if (ruleTaskIds.length > 0) {
await prisma.notification.deleteMany({
where: {
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
}
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
await prisma.notification.deleteMany({
where: {
relatedType: 'Suggestion',
relatedId: { in: ruleSuggestionIds },
},
});
await prisma.suggestion.deleteMany({
where: { id: { in: ruleSuggestionIds } },
});
const aggregateIds = [...batchIds, ...movementIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await app.close();
});
@@ -363,22 +355,22 @@ describe('Raw materials and production (e2e)', () => {
).expect(409);
});
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
it('proposes the RAW_MATERIAL_LOW reorder only once its StockChanged event is dispatched', async () => {
// The rule is shared by every e2e file: switch it on with a minimum no
// test database reaches, and put it back right after.
const before = await prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
// One open task per kind: a leftover from an aborted earlier run would
// keep the handler from creating a new one - close it first.
await prisma.task.updateMany({
const config = { minGrams: { PROPOLIS: 1_000_000_000 } };
// One open suggestion per kind: a leftover from an aborted earlier run
// would keep the handler from proposing a new one - settle it first.
await prisma.suggestion.updateMany({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
status: { in: ['OPEN', 'IN_PROGRESS'] },
status: 'OPEN',
},
data: { status: 'DONE', completedAt: new Date() },
data: { status: 'DISMISSED' },
});
await prisma.automationRule.upsert({
where: { triggerType: 'RAW_MATERIAL_LOW' },
@@ -400,23 +392,35 @@ describe('Raw materials and production (e2e)', () => {
.expect(201);
// Newest first: the withdrawal just booked.
const [movement] = (response.body as StockBody).movements;
const [event] = await prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId: movement.id },
select: { correlationId: true },
});
const ruleTasksOfThisChange = async () =>
(
await prisma.domainEvent.findMany({
where: {
type: 'TaskCreated',
correlationId: event.correlationId,
},
select: { aggregateId: true },
})
).map((created) => created.aggregateId);
const suggestionsOfThisChange = () =>
prisma.suggestion.findMany({
where: {
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
// The request itself creates no task - the handler does.
expect(await ruleTasksOfThisChange()).toEqual([]);
// The request itself proposes nothing - the handler does.
expect(await suggestionsOfThisChange()).toEqual([]);
await drainDomainEvents(app, [movement.id]);
const suggestions = await suggestionsOfThisChange();
ruleSuggestionIds.push(...suggestions.map((found) => found.id));
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
ruleKey: 'RAW_MATERIAL_LOW',
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
colonyId: null,
requiredPermission: 'raw-material:read',
status: 'OPEN',
dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`,
});
// The request creates no task either.
expect(
await prisma.task.count({
where: {
@@ -426,20 +430,6 @@ describe('Raw materials and production (e2e)', () => {
},
}),
).toBe(0);
await drainDomainEvents(app, [movement.id]);
const taskIds = await ruleTasksOfThisChange();
ruleTaskIds.push(...taskIds);
expect(taskIds).toHaveLength(1);
expect(
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
).toMatchObject({
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
status: 'OPEN',
});
} finally {
// Leave the rule as found: restored, or gone again if this test
// created it (ensureDefaults then recreates it with its defaults).
@@ -245,30 +245,20 @@ describe('Suggestions API (e2e)', () => {
expect(other.body).toEqual([]);
});
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
await proposeForTest(app, {
it('a user without varroa:read does not get the varroa suggestion in the list', async () => {
const created = await proposeForTest(app, {
...proposal('v1'),
ruleKey: 'VARROA_THRESHOLD',
intent: 'VARROA_TREAT',
requiredPermission: 'varroa:read',
});
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
const hiddenSummary = await get(tokens.noVarroa, '/summary');
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
const seenSummary = await get(tokens.full, '/summary');
expect(hidden.body).toEqual([]);
expect((hiddenSummary.body as { open: number }).open).toBe(0);
expect(seen.body).toHaveLength(1);
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
1,
);
expect(
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
).toEqual(
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
);
const hidden = (await get(tokens.noVarroa, `?colonyId=${colonyId}`))
.body as ViewBody[];
const seen = (await get(tokens.full, `?colonyId=${colonyId}`))
.body as ViewBody[];
expect(hidden.map((view) => view.id)).not.toContain(created.id);
expect(seen.map((view) => view.id)).toContain(created.id);
});
it('answers 404 to a decision on a suggestion the user may not see', async () => {
+231 -7
View File
@@ -1,9 +1,14 @@
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 { SuggestionsService } from '../src/suggestions/suggestions.service';
import type { ProposeInput } from '../src/suggestions/suggestion.types';
import { berlinToday } from '../src/common/dates';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
@@ -17,6 +22,9 @@ describe('Suggestions (e2e)', () => {
let apiaryId: string;
let colonyId: string;
let userId: string;
const roleIds: string[] = [];
const extraUserIds: string[] = [];
const password = 'SuperSecret123!';
const proposal = (anchor: string): ProposeInput => ({
ruleKey: 'SPLIT_SCHEDULE',
source: 'CHAIN',
@@ -69,23 +77,64 @@ describe('Suggestions (e2e)', () => {
});
afterAll(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
await clearSuggestions();
const inspectionIds = (
await prisma.inspection.findMany({
where: { colonyId },
select: { id: true },
})
).map((inspection) => inspection.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspectionIds } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: extraUserIds } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [userId, ...extraUserIds] } },
});
await prisma.user.deleteMany({
where: { id: { in: [userId, ...extraUserIds] } },
});
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
afterEach(async () => {
/** The colony's suggestions with what hangs on them: tasks, notifications, audit. */
async function clearSuggestions(): Promise<void> {
const ids = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ids } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
},
],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
});
}
afterEach(clearSuggestions);
it('proposes once per anchor and writes an audit entry', async () => {
const created = await propose(proposal('a1'));
@@ -412,4 +461,179 @@ describe('Suggestions (e2e)', () => {
expect(obsolete.status).toBe('OBSOLETE');
expect(obsolete.dedupeKey).not.toBeNull();
});
describe('rule suggestions', () => {
let writer: { id: string; token: string };
let withoutInspections: { id: string };
let withoutTasks: { id: string };
async function createUser(
label: string,
permissions: string[],
): Promise<{ id: string; token: string }> {
const role = await prisma.role.create({
data: {
key: `SUG_RULES_${label.toUpperCase()}_${suffix}`,
name: `Suggestions rules ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `suggestions-rules-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Suggestions rules ${label}`,
role: { connect: { id: role.id } },
},
});
extraUserIds.push(user.id);
return {
id: user.id,
token: (await login(app, email, password)).accessToken,
};
}
beforeAll(async () => {
writer = await createUser('writer', [
'inspections:create',
'inspections:read',
'tasks:read',
]);
withoutInspections = await createUser('noinsp', ['tasks:read']);
withoutTasks = await createUser('notasks', ['inspections:read']);
});
/** Records an inspection and runs the handlers of its event. */
async function inspect(body: object): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${writer.token}`)
.send({ colonyId, date: new Date().toISOString(), ...body })
.expect(201);
const id = (response.body as { id: string }).id;
await drainDomainEvents(app, [id]);
return id;
}
const queenless = { queenSeen: false, eggsPresent: false };
const ruleSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' },
});
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => {
const inspectionId = await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
0,
);
});
it('a dismissed suggestion is not proposed again for the same inspection', async () => {
const inspectionId = await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
// The delivery runs again, as after a retry or a catch-up.
await prisma.domainEvent.updateMany({
where: { aggregateId: inspectionId },
data: { status: 'PENDING', processedAt: null, claimedAt: null },
});
await prisma.domainEventDelivery.updateMany({
where: {
handler: 'automation.inspection-rules',
event: { aggregateId: inspectionId },
},
data: { status: 'PENDING', attempts: 0, completedAt: null },
});
await drainDomainEvents(app, [inspectionId]);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0].status).toBe('DISMISSED');
});
it('a later inspection with the same finding proposes again', async () => {
await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual(
['DISMISSED', 'OPEN'],
);
});
it('accepting a rule suggestion without dueInDays creates a task due today', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
expect(open.dueDate).toBeNull();
const result = await service.accept(open.id, {}, userId);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe(
berlinToday(),
);
expect(result.tasks[0]).toMatchObject({
sourceSuggestionId: open.id,
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
priority: 'HIGH',
});
});
it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => {
const created = (await prisma.$transaction((tx) =>
service.proposeIn(tx, {
...proposal('notify-chain'),
notify: true,
requiredPermission: 'colonies:read',
}),
))!;
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: created.id },
});
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe(
true,
);
});
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: open.id },
});
const recipients = new Set(rows.map((row) => row.userId));
expect(recipients.has(writer.id)).toBe(true);
expect(recipients.has(withoutInspections.id)).toBe(false);
expect(recipients.has(withoutTasks.id)).toBe(false);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(
rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)),
).toBe(true);
});
});
});
@@ -89,6 +89,7 @@ describe('Treatments (e2e)', () => {
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -482,6 +483,9 @@ describe('Treatments (e2e)', () => {
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
await prisma.idempotencyRecord.deleteMany({
@@ -5,6 +5,7 @@ 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;
@@ -41,6 +42,9 @@ describe('Varroa seasonal bands (e2e)', () => {
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())
@@ -80,7 +84,39 @@ describe('Varroa seasonal bands (e2e)', () => {
});
afterAll(async () => {
const colonyIds = [productionColonyId, youngColonyId];
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 } },
});
@@ -137,6 +173,53 @@ describe('Varroa seasonal bands (e2e)', () => {
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);
});
@@ -221,4 +304,87 @@ describe('Varroa seasonal bands (e2e)', () => {
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);
});
});
});