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:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user