feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -89,6 +89,9 @@ describe('Apiaries (e2e)', () => {
|
||||
await prisma.task.deleteMany({
|
||||
where: { title: { startsWith: 'field-e2e-' } },
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colony: { apiaryId: fieldApiaryId } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } });
|
||||
await prisma.apiary.delete({ where: { id: fieldApiaryId } });
|
||||
}
|
||||
@@ -210,7 +213,14 @@ describe('Apiaries (e2e)', () => {
|
||||
describe('apiary field endpoints', () => {
|
||||
interface OverviewBody {
|
||||
apiaryId: string;
|
||||
rows: { code: string }[];
|
||||
rows: {
|
||||
code: string;
|
||||
inspectionDueOn: string | null;
|
||||
segment: string;
|
||||
nextStepTitle: string | null;
|
||||
openWarnings: number;
|
||||
openCritical: number;
|
||||
}[];
|
||||
}
|
||||
interface PackBody {
|
||||
version: number;
|
||||
@@ -285,6 +295,109 @@ describe('Apiaries (e2e)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', async () => {
|
||||
const permissions = [
|
||||
'colonies:read',
|
||||
'tasks:read',
|
||||
'inspections:read',
|
||||
'varroa:read',
|
||||
];
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `FIELD_OVERVIEW_${Date.now()}`,
|
||||
name: 'Feld-Übersicht',
|
||||
permissions: {
|
||||
create: permissions.map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
const email = `apiaries-e2e-overview-${Date.now()}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'Overview',
|
||||
roleId: role.id,
|
||||
},
|
||||
});
|
||||
const colony = await prisma.colony.findFirstOrThrow({
|
||||
where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } },
|
||||
});
|
||||
const base = {
|
||||
ruleKey: 'VARROA_THRESHOLD' as const,
|
||||
source: 'RULE' as const,
|
||||
mode: 'PLAN' as const,
|
||||
intent: 'VARROA_TREAT' as const,
|
||||
relatedType: 'COLONY' as const,
|
||||
relatedId: colony.id,
|
||||
colonyId: colony.id,
|
||||
reasonCode: 'FIELD_E2E',
|
||||
evidence: { colonyId: colony.id },
|
||||
};
|
||||
try {
|
||||
await prisma.suggestion.createMany({
|
||||
data: [
|
||||
{ ...base, priority: 'HIGH', requiredPermission: 'varroa:read' },
|
||||
{
|
||||
...base,
|
||||
intent: 'HONEY_SUPER',
|
||||
priority: 'URGENT',
|
||||
requiredPermission: 'inspections:read',
|
||||
},
|
||||
{
|
||||
...base,
|
||||
intent: 'FEED',
|
||||
priority: 'URGENT',
|
||||
requiredPermission: 'feedings:read',
|
||||
},
|
||||
{ ...base, intent: 'INSPECT', priority: 'NORMAL' },
|
||||
],
|
||||
});
|
||||
const token = (await login(app, email, password)).accessToken;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
|
||||
const rows = (response.body as OverviewBody).rows;
|
||||
const row = rows.find((item) => item.code.startsWith('V-10-'));
|
||||
// HIGH (varroa:read) and URGENT (inspections:read) are visible, the
|
||||
// URGENT one that needs feedings:read is not.
|
||||
expect(row).toMatchObject({ openWarnings: 1, openCritical: 1 });
|
||||
expect(row?.nextStepTitle).toEqual(expect.any(String));
|
||||
expect(row?.segment).toEqual(expect.any(String));
|
||||
expect(row).toHaveProperty('inspectionDueOn');
|
||||
if (row?.segment === 'WINTERRUHE') {
|
||||
expect(row.inspectionDueOn).toBeNull();
|
||||
} else {
|
||||
expect(row?.inspectionDueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
}
|
||||
const quiet = rows.find((item) => item.code.startsWith('V-2-'));
|
||||
expect(quiet).toMatchObject({
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
nextStepTitle: null,
|
||||
});
|
||||
|
||||
const pack = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
const steps = (pack.body as { nextSteps?: Record<string, unknown[]> })
|
||||
.nextSteps;
|
||||
expect(steps?.[colony.id]?.length).toBeGreaterThan(0);
|
||||
expect(steps?.[colony.id]?.length).toBeLessThanOrEqual(3);
|
||||
} finally {
|
||||
await prisma.suggestion.deleteMany({ where: { colonyId: colony.id } });
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
await prisma.role.delete({ where: { id: role.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('offline-pack contains the open tasks of the apiary and its colonies', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('Automation rules (e2e)', () => {
|
||||
let staffToken: string;
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
const followColonyIds: string[] = [];
|
||||
let varroaRuleId: string;
|
||||
let swarmRuleId: string;
|
||||
|
||||
@@ -112,13 +113,17 @@ describe('Automation rules (e2e)', () => {
|
||||
// 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 colonyIds = [colonyId, ...followColonyIds];
|
||||
const suggestionIds = (
|
||||
await prisma.suggestion.findMany({
|
||||
where: { colonyId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((suggestion) => suggestion.id);
|
||||
const byColony = { where: { colonyId }, select: { id: true } };
|
||||
const byColony = {
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
select: { id: true },
|
||||
};
|
||||
const recordIds = [
|
||||
...(await prisma.inspection.findMany(byColony)),
|
||||
...(await prisma.varroaMeasurement.findMany(byColony)),
|
||||
@@ -127,10 +132,10 @@ describe('Automation rules (e2e)', () => {
|
||||
where: {
|
||||
OR: [
|
||||
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||
{
|
||||
...colonyIds.map((id) => ({
|
||||
entityType: 'AutomationRule',
|
||||
afterData: { path: ['relatedId'], equals: colonyId },
|
||||
},
|
||||
afterData: { path: ['relatedId'], equals: id },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -143,11 +148,19 @@ describe('Automation rules (e2e)', () => {
|
||||
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 } });
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.varroaMeasurement.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.swarmControlCheck.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.inspection.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [adminId, staffId] } },
|
||||
@@ -448,6 +461,194 @@ describe('Automation rules (e2e)', () => {
|
||||
expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null });
|
||||
});
|
||||
|
||||
describe('an open inspection suggestion follows newer inspections', () => {
|
||||
const ANCHOR_DAY = '2026-05-05T00:00:00.000Z';
|
||||
|
||||
async function freshColony(): Promise<string> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `AUTO-F-${Date.now()}-${followColonyIds.length}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
followColonyIds.push(colony.id);
|
||||
return colony.id;
|
||||
}
|
||||
|
||||
async function inspectOn(
|
||||
forColony: string,
|
||||
date: string,
|
||||
body: object,
|
||||
): Promise<string> {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/inspections')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ colonyId: forColony, date, ...body })
|
||||
.expect(201);
|
||||
const id = (response.body as { id: string }).id;
|
||||
await drainDomainEvents(app, [id]);
|
||||
return id;
|
||||
}
|
||||
|
||||
const queenlessOf = (forColony: string) =>
|
||||
prisma.suggestion.findMany({
|
||||
where: { colonyId: forColony, ruleKey: 'QUEENLESS_NO_EGGS' },
|
||||
});
|
||||
const queenless = { queenSeen: false, eggsPresent: false };
|
||||
const clean = { queenSeen: true, eggsPresent: true };
|
||||
|
||||
it('a newer clean inspection resolves it', async () => {
|
||||
const forColony = await freshColony();
|
||||
await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
expect(await queenlessOf(forColony)).toMatchObject([{ status: 'OPEN' }]);
|
||||
|
||||
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean);
|
||||
|
||||
expect(await queenlessOf(forColony)).toMatchObject([
|
||||
{ status: 'RESOLVED', dedupeKey: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a newer inspection with the finding keeps one OPEN suggestion with updated evidence', async () => {
|
||||
const forColony = await freshColony();
|
||||
await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
|
||||
const newerId = await inspectOn(
|
||||
forColony,
|
||||
'2026-05-12T00:00:00.000Z',
|
||||
queenless,
|
||||
);
|
||||
|
||||
const suggestions = await queenlessOf(forColony);
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0]).toMatchObject({
|
||||
status: 'OPEN',
|
||||
evidence: { inspectionId: newerId, inspectedOn: '2026-05-12' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a backdated inspection changes nothing', async () => {
|
||||
const forColony = await freshColony();
|
||||
await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
const [before] = await queenlessOf(forColony);
|
||||
|
||||
await inspectOn(forColony, '2026-04-20T00:00:00.000Z', clean);
|
||||
|
||||
const [after] = await queenlessOf(forColony);
|
||||
expect(after).toMatchObject({
|
||||
status: 'OPEN',
|
||||
evidence: before.evidence,
|
||||
});
|
||||
});
|
||||
|
||||
it('correcting the inspection of the finding keeps it OPEN while the finding remains', async () => {
|
||||
const forColony = await freshColony();
|
||||
const id = await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ queenSeen: false, eggsPresent: false, notes: 'korrigiert' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [id]);
|
||||
|
||||
expect(await queenlessOf(forColony)).toMatchObject([
|
||||
{ status: 'OPEN', evidence: { inspectionId: id } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('correcting an older inspection than the latest changes nothing', async () => {
|
||||
const forColony = await freshColony();
|
||||
const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', queenless);
|
||||
const [before] = await queenlessOf(forColony);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${olderId}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ queenSeen: true, eggsPresent: true })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [olderId]);
|
||||
|
||||
const [after] = await queenlessOf(forColony);
|
||||
expect(after).toMatchObject({
|
||||
status: 'OPEN',
|
||||
evidence: before.evidence,
|
||||
});
|
||||
});
|
||||
|
||||
it('a late retry of an older inspection after a newer clean one proposes nothing', async () => {
|
||||
const forColony = await freshColony();
|
||||
const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean);
|
||||
expect(await queenlessOf(forColony)).toMatchObject([
|
||||
{ status: 'RESOLVED' },
|
||||
]);
|
||||
|
||||
// The delivery of the older inspection runs again.
|
||||
await prisma.domainEvent.updateMany({
|
||||
where: { aggregateId: olderId },
|
||||
data: { status: 'PENDING', processedAt: null, claimedAt: null },
|
||||
});
|
||||
await prisma.domainEventDelivery.updateMany({
|
||||
where: {
|
||||
handler: 'automation.inspection-rules',
|
||||
event: { aggregateId: olderId },
|
||||
},
|
||||
data: { status: 'PENDING', attempts: 0, completedAt: null },
|
||||
});
|
||||
await drainDomainEvents(app, [olderId]);
|
||||
|
||||
expect(await queenlessOf(forColony)).toMatchObject([
|
||||
{ status: 'RESOLVED' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('the feed rule follows newer inspections the same way', async () => {
|
||||
const forColony = await freshColony();
|
||||
await inspectOn(forColony, ANCHOR_DAY, {
|
||||
...clean,
|
||||
feedStatus: 'CRITICAL',
|
||||
});
|
||||
const feedOf = () =>
|
||||
prisma.suggestion.findMany({
|
||||
where: { colonyId: forColony, ruleKey: 'FEED_STATUS_CRITICAL' },
|
||||
});
|
||||
expect(await feedOf()).toMatchObject([{ status: 'OPEN' }]);
|
||||
|
||||
const stillId = await inspectOn(forColony, '2026-05-12T00:00:00.000Z', {
|
||||
...clean,
|
||||
feedStatus: 'CRITICAL',
|
||||
});
|
||||
expect(await feedOf()).toMatchObject([
|
||||
{ status: 'OPEN', evidence: { inspectionId: stillId } },
|
||||
]);
|
||||
|
||||
await inspectOn(forColony, '2026-05-19T00:00:00.000Z', {
|
||||
...clean,
|
||||
feedStatus: 'GOOD',
|
||||
});
|
||||
expect(await feedOf()).toMatchObject([{ status: 'RESOLVED' }]);
|
||||
});
|
||||
|
||||
it('correcting the inspection of the finding resolves it', async () => {
|
||||
const forColony = await freshColony();
|
||||
const id = await inspectOn(forColony, ANCHOR_DAY, queenless);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ queenSeen: true, eggsPresent: true })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [id]);
|
||||
|
||||
expect(await queenlessOf(forColony)).toMatchObject([
|
||||
{ status: 'RESOLVED' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
|
||||
const before = await prisma.automationRule.findUniqueOrThrow({
|
||||
where: { id: varroaRuleId },
|
||||
|
||||
@@ -15,6 +15,7 @@ interface BottlingBatchBody {
|
||||
id: string;
|
||||
totalAmount: number;
|
||||
lotNumber: string;
|
||||
warnings: unknown[];
|
||||
}
|
||||
|
||||
interface RecordedEvent {
|
||||
@@ -187,6 +188,8 @@ describe('Bottling (e2e)', () => {
|
||||
const bottlingBatch = response.body as BottlingBatchBody;
|
||||
bottlingBatchId = bottlingBatch.id;
|
||||
expect(bottlingBatch.totalAmount).toBe(25);
|
||||
// The harvest behind the honey batch has no withdrawal window.
|
||||
expect(bottlingBatch.warnings).toEqual([]);
|
||||
|
||||
const honeyBatchResponse = await request(app.getHttpServer())
|
||||
.get(`/api/v1/honey-batches/${honeyBatchId}`)
|
||||
@@ -217,11 +220,12 @@ describe('Bottling (e2e)', () => {
|
||||
});
|
||||
|
||||
it('a correction records BottlingCorrected and StockChanged together', async () => {
|
||||
await request(app.getHttpServer())
|
||||
const corrected = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/bottling-batches/${bottlingBatchId}`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.send({ jarCount: 40 })
|
||||
.expect(200);
|
||||
expect((corrected.body as BottlingBatchBody).warnings).toEqual([]);
|
||||
|
||||
// 10 jars of 500 g fewer: 5 kg go back to the honey batch.
|
||||
expect(await honeyBatchAmount()).toBe(5);
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('Calendar sync outbox (e2e)', () => {
|
||||
.mockImplementationOnce(async (_g, input) => {
|
||||
createdEvents.push(input.title);
|
||||
// The task's due date is changed right now, on another request.
|
||||
await calendarSync.enqueue('TASK', task.id);
|
||||
await calendarSync.enqueueIn(prisma, 'TASK', task.id);
|
||||
return `event-${suffix}-edited`;
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface CalendarEventBody {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
date: string;
|
||||
endDate?: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
}
|
||||
|
||||
describe('Calendar (e2e)', () => {
|
||||
@@ -21,6 +26,11 @@ describe('Calendar (e2e)', () => {
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let taskId: string;
|
||||
let treatmentId: string;
|
||||
let colonyCode: string;
|
||||
let calendarOnlyRoleId: string;
|
||||
let calendarOnlyId: string;
|
||||
let calendarOnlyToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -50,6 +60,39 @@ describe('Calendar (e2e)', () => {
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
colonyCode = colony.code;
|
||||
// 2027-06-10 + 10 days: harvestable again from 2027-06-20.
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId: beekeeperId,
|
||||
date: new Date('2027-06-10T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 10,
|
||||
},
|
||||
});
|
||||
treatmentId = treatment.id;
|
||||
const calendarOnlyRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `CAL_ONLY_${Date.now()}`,
|
||||
name: 'Nur Kalender',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'calendar:read' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
calendarOnlyRoleId = calendarOnlyRole.id;
|
||||
const calendarOnly = await prisma.user.create({
|
||||
data: {
|
||||
email: `calendar-e2e-only-${Date.now()}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Calendar only',
|
||||
roleId: calendarOnlyRoleId,
|
||||
},
|
||||
});
|
||||
calendarOnlyId = calendarOnly.id;
|
||||
calendarOnlyToken = (await login(app, calendarOnly.email, password))
|
||||
.accessToken;
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: `Kalendertest ${Date.now()}`,
|
||||
@@ -62,11 +105,16 @@ describe('Calendar (e2e)', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.task.deleteMany({ where: { id: taskId } });
|
||||
await prisma.treatment.deleteMany({ where: { id: treatmentId } });
|
||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
|
||||
await prisma.user.deleteMany({ where: { id: beekeeperId } });
|
||||
const userIds = [beekeeperId, calendarOnlyId];
|
||||
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: calendarOnlyRoleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -90,4 +138,84 @@ describe('Calendar (e2e)', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const events = response.body as CalendarEventBody[];
|
||||
|
||||
const span = events.find(
|
||||
(event) =>
|
||||
event.type === 'WITHDRAWAL' && event.id === `withdrawal:${treatmentId}`,
|
||||
);
|
||||
expect(span).toMatchObject({
|
||||
title: `Wartezeit ${colonyCode} · Oxuvar`,
|
||||
relatedType: 'Colony',
|
||||
relatedId: colonyId,
|
||||
});
|
||||
expect(span?.date.slice(0, 10)).toBe('2027-06-10');
|
||||
// The last covered day, inclusive; 2027-06-20 is harvestable again.
|
||||
expect(span?.endDate?.slice(0, 10)).toBe('2027-06-19');
|
||||
});
|
||||
|
||||
it('shows the span in a range that only touches the end of the window', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-19&to=2027-06-25')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const events = response.body as CalendarEventBody[];
|
||||
|
||||
expect(
|
||||
events.some((event) => event.id === `withdrawal:${treatmentId}`),
|
||||
).toBe(true);
|
||||
const later = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-20&to=2027-06-25')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
expect(
|
||||
(later.body as CalendarEventBody[]).some(
|
||||
(event) => event.id === `withdrawal:${treatmentId}`,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides WITHDRAWAL from users without treatments:read', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${calendarOnlyToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(
|
||||
(response.body as CalendarEventBody[]).filter(
|
||||
(event) => event.type === 'WITHDRAWAL',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists TREATMENT events only for users with treatments:read', async () => {
|
||||
const asBeekeeper = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const asCalendarOnly = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${calendarOnlyToken}`)
|
||||
.expect(200);
|
||||
|
||||
const treatmentEvent = (body: unknown) =>
|
||||
(body as CalendarEventBody[]).filter(
|
||||
(event) => event.type === 'TREATMENT' && event.id === treatmentId,
|
||||
);
|
||||
expect(treatmentEvent(asBeekeeper.body)).toHaveLength(1);
|
||||
expect(treatmentEvent(asCalendarOnly.body)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an invalid date range', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=nonsense&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { berlinToday } from '../src/common/dates';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
@@ -270,7 +271,14 @@ describe('Colonies (e2e)', () => {
|
||||
const ended = await prisma.domainEvent.findMany({
|
||||
where: { type: 'ColonyEnded', aggregateId: colonyToMergeId },
|
||||
});
|
||||
expect(ended.map((event) => event.payload)).toEqual([{ status: 'MERGED' }]);
|
||||
expect(ended.map((event) => event.payload)).toEqual([
|
||||
{
|
||||
status: 'MERGED',
|
||||
endedOn: berlinToday(),
|
||||
releasedQueenId: null,
|
||||
via: 'MERGE',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('dissolves the colony without deleting it', async () => {
|
||||
@@ -288,7 +296,12 @@ describe('Colonies (e2e)', () => {
|
||||
'ColonyStatusChanged',
|
||||
'ColonyEnded',
|
||||
]);
|
||||
expect(events[1].payload).toEqual({ status: 'DISSOLVED' });
|
||||
expect(events[1].payload).toEqual({
|
||||
status: 'DISSOLVED',
|
||||
endedOn: berlinToday(),
|
||||
releasedQueenId: null,
|
||||
via: 'DISSOLVE',
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.get(`/api/v1/colonies/${colonyId}`)
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
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 { login } from './utils/api';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
import { proposeForTest } from './utils/suggestions';
|
||||
|
||||
interface CleanupBody {
|
||||
cancelled: number;
|
||||
completed: number;
|
||||
kept: number;
|
||||
queenStatus: string | null;
|
||||
}
|
||||
|
||||
interface EndedColony {
|
||||
colonyId: string;
|
||||
queenId: string;
|
||||
taskIds: string[];
|
||||
suggestionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A colony that ends (winter loss, edit to SOLD): the queen assignment ends
|
||||
* in the same transaction, open suggestions turn OBSOLETE, one clean-up
|
||||
* suggestion is left, and "Aufräumen" settles tasks and queen. Every
|
||||
* assertion is on this file's own colonies.
|
||||
*/
|
||||
describe('Colony ended (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const FULL_PERMISSIONS = [
|
||||
'tasks:read',
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:complete',
|
||||
'queens:update',
|
||||
'colonies:read',
|
||||
'colonies:update',
|
||||
'colonies:deactivate',
|
||||
'winter-checks:read',
|
||||
'winter-checks:create',
|
||||
];
|
||||
let apiaryId: string;
|
||||
const colonyIds: string[] = [];
|
||||
const queenIds: string[] = [];
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const tokens = { full: '', noComplete: '', noQueens: '' };
|
||||
let counter = 0;
|
||||
const nextKey = () => `colony-ended-${suffix}-${(counter += 1)}-key`;
|
||||
|
||||
const api = () => request(app.getHttpServer());
|
||||
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||
|
||||
async function createUser(
|
||||
label: string,
|
||||
permissions: string[],
|
||||
): Promise<string> {
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `COLONY_ENDED_${label.toUpperCase()}_${suffix}`,
|
||||
name: `Colony ended ${label}`,
|
||||
permissions: {
|
||||
create: permissions.map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
const email = `colony-ended-${label}-${suffix}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: `Colony ended ${label}`,
|
||||
role: { connect: { id: role.id } },
|
||||
},
|
||||
});
|
||||
userIds.push(user.id);
|
||||
return (await login(app, email, password)).accessToken;
|
||||
}
|
||||
|
||||
/** A living colony with an active queen assigned today and some open tasks. */
|
||||
async function newColony(
|
||||
label: string,
|
||||
taskCount = 3,
|
||||
): Promise<{ colonyId: string; queenId: string; taskIds: string[] }> {
|
||||
const queen = await prisma.queen.create({
|
||||
data: { code: `CE-Q-${label}-${suffix}`, status: 'ACTIVE' },
|
||||
});
|
||||
queenIds.push(queen.id);
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `CE-${label}-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
currentQueenId: queen.id,
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
await prisma.queenColonyHistory.create({
|
||||
data: {
|
||||
queenId: queen.id,
|
||||
colonyId: colony.id,
|
||||
startDate: new Date('2002-04-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
const taskIds: string[] = [];
|
||||
for (let index = 0; index < taskCount; index += 1) {
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: `Aufgabe ${index}`,
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colony.id,
|
||||
},
|
||||
});
|
||||
taskIds.push(task.id);
|
||||
}
|
||||
return { colonyId: colony.id, queenId: queen.id, taskIds };
|
||||
}
|
||||
|
||||
/** Ends a colony through the edit route and returns its clean-up suggestion. */
|
||||
async function endColony(label: string): Promise<EndedColony> {
|
||||
const colony = await newColony(label);
|
||||
await api()
|
||||
.patch(`/api/v1/colonies/${colony.colonyId}`)
|
||||
.set(auth(tokens.full))
|
||||
.send({ status: 'SOLD' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [colony.colonyId]);
|
||||
const suggestion = await prisma.suggestion.findFirstOrThrow({
|
||||
where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' },
|
||||
});
|
||||
return { ...colony, suggestionId: suggestion.id };
|
||||
}
|
||||
|
||||
const applyCleanup = (token: string, suggestionId: string, body: object) =>
|
||||
api()
|
||||
.post(`/api/v1/suggestions/${suggestionId}/apply-cleanup`)
|
||||
.set(auth(token))
|
||||
.set('Idempotency-Key', nextKey())
|
||||
.send(body);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Colony ended ${suffix}` } })
|
||||
).id;
|
||||
tokens.full = await createUser('full', FULL_PERMISSIONS);
|
||||
tokens.noQueens = await createUser(
|
||||
'noqueens',
|
||||
FULL_PERMISSIONS.filter((key) => key !== 'queens:update'),
|
||||
);
|
||||
tokens.noComplete = await createUser(
|
||||
'nocomplete',
|
||||
FULL_PERMISSIONS.filter((key) => key !== 'tasks:complete'),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const taskIds = (
|
||||
await prisma.task.findMany({
|
||||
where: { relatedId: { in: colonyIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((row) => row.id);
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { in: [...taskIds, ...colonyIds, ...queenIds] } },
|
||||
});
|
||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
|
||||
});
|
||||
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.winterCheck.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.queenColonyHistory.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.queen.deleteMany({ where: { id: { in: queenIds } } });
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
await prisma.idempotencyRecord.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
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('a winter loss with markColonyDeceased ends the queen assignment in the same transaction', async () => {
|
||||
const { colonyId, queenId } = await newColony('winter', 0);
|
||||
|
||||
await api()
|
||||
.post('/api/v1/winter-checks')
|
||||
.set(auth(tokens.full))
|
||||
.send({
|
||||
colonyId,
|
||||
season: 'SPRING',
|
||||
date: '2002-03-20',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'DEAD_OR_EMPTY',
|
||||
markColonyDeceased: true,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const colony = await prisma.colony.findUniqueOrThrow({
|
||||
where: { id: colonyId },
|
||||
});
|
||||
expect(colony).toMatchObject({ status: 'DECEASED', currentQueenId: null });
|
||||
const history = await prisma.queenColonyHistory.findMany({
|
||||
where: { colonyId, queenId },
|
||||
});
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].endDate?.toISOString()).toBe('2002-03-20T00:00:00.000Z');
|
||||
// The queen herself keeps her status: the clean-up decides.
|
||||
await expect(
|
||||
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
const ended = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: colonyId, type: 'ColonyEnded' },
|
||||
});
|
||||
expect(ended.map((event) => event.payload)).toEqual([
|
||||
{
|
||||
status: 'DECEASED',
|
||||
endedOn: '2002-03-20',
|
||||
releasedQueenId: queenId,
|
||||
via: 'WINTER_CHECK',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('open suggestions become OBSOLETE after draining and one CLEANUP suggestion is left', async () => {
|
||||
const { colonyId, taskIds, queenId } = await newColony('obsolete', 2);
|
||||
const open = await proposeForTest(app, {
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
source: 'RULE',
|
||||
mode: 'DO',
|
||||
intent: 'INSPECT',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: `COLONY_ENDED_E2E_${suffix}`,
|
||||
evidence: { colonyId },
|
||||
requiredPermission: 'inspections:read',
|
||||
dueDate: '2026-05-12',
|
||||
anchor: `ce-${suffix}`,
|
||||
});
|
||||
|
||||
await api()
|
||||
.patch(`/api/v1/colonies/${colonyId}`)
|
||||
.set(auth(tokens.full))
|
||||
.send({ status: 'DECEASED' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [colonyId]);
|
||||
|
||||
await expect(
|
||||
prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }),
|
||||
).resolves.toMatchObject({ status: 'OBSOLETE' });
|
||||
const cleanups = await prisma.suggestion.findMany({
|
||||
where: { colonyId, intent: 'CLEANUP' },
|
||||
});
|
||||
expect(cleanups).toHaveLength(1);
|
||||
expect(cleanups[0]).toMatchObject({
|
||||
status: 'OPEN',
|
||||
mode: 'DO',
|
||||
ruleKey: 'COLONY_ENDED_CLEANUP',
|
||||
requiredPermission: 'colonies:read',
|
||||
});
|
||||
const evidence = cleanups[0].evidence as Record<string, unknown>;
|
||||
expect(evidence).toMatchObject({
|
||||
colonyId,
|
||||
status: 'DECEASED',
|
||||
via: 'EDIT',
|
||||
queenId,
|
||||
});
|
||||
expect(evidence.openTaskIds).toEqual([...taskIds].sort());
|
||||
const colony = await prisma.colony.findUniqueOrThrow({
|
||||
where: { id: colonyId },
|
||||
});
|
||||
expect(colony.currentQueenId).toBeNull();
|
||||
});
|
||||
|
||||
it('apply-cleanup cancels 2 tasks and completes 1, then the suggestion is RESOLVED', async () => {
|
||||
const ended = await endColony('apply');
|
||||
const [first, second, third] = [...ended.taskIds].sort();
|
||||
|
||||
const response = await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [
|
||||
{ id: first, outcome: 'CANCEL' },
|
||||
{ id: second, outcome: 'CANCEL' },
|
||||
{ id: third, outcome: 'DONE' },
|
||||
],
|
||||
queen: { id: ended.queenId, status: 'SOLD' },
|
||||
}).expect(201);
|
||||
|
||||
expect(response.body as CleanupBody).toEqual({
|
||||
cancelled: 2,
|
||||
completed: 1,
|
||||
kept: 0,
|
||||
queenStatus: 'SOLD',
|
||||
});
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { id: { in: ended.taskIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(tasks.map((task) => task.status)).toEqual([
|
||||
'CANCELLED',
|
||||
'CANCELLED',
|
||||
'DONE',
|
||||
]);
|
||||
expect(tasks[2].completedAt).not.toBeNull();
|
||||
await expect(
|
||||
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
|
||||
).resolves.toMatchObject({ status: 'SOLD' });
|
||||
await drainDomainEvents(app, [first, second]);
|
||||
for (const id of [first, second]) {
|
||||
const updates = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: id, type: 'TaskUpdated' },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].payload).toMatchObject({ status: 'CANCELLED' });
|
||||
expect(updates[0].deliveries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
handler: 'calendar-sync.task',
|
||||
status: 'DONE',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
const suggestion = await prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: ended.suggestionId },
|
||||
});
|
||||
expect(suggestion).toMatchObject({
|
||||
status: 'RESOLVED',
|
||||
decisionNote: 'Aufgeräumt',
|
||||
dedupeKey: null,
|
||||
});
|
||||
await expect(
|
||||
prisma.auditLog.count({
|
||||
where: {
|
||||
entityType: 'Suggestion',
|
||||
entityId: ended.suggestionId,
|
||||
action: 'UPDATE',
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('apply-cleanup DONE writes the audit entry and TaskCompleted like a normal completion (delivered after draining)', async () => {
|
||||
const ended = await endColony('done');
|
||||
const [first, ...rest] = [...ended.taskIds].sort();
|
||||
|
||||
await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [
|
||||
{ id: first, outcome: 'DONE' },
|
||||
...rest.map((id) => ({ id, outcome: 'KEEP' })),
|
||||
],
|
||||
}).expect(201);
|
||||
await drainDomainEvents(app, [first]);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { entityType: 'Task', entityId: first, action: 'UPDATE' },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0].afterData).toMatchObject({ status: 'DONE' });
|
||||
const completed = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: first, type: 'TaskCompleted' },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].deliveries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
handler: 'calendar-sync.task',
|
||||
status: 'DONE',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const kept = await prisma.task.findMany({
|
||||
where: { id: { in: rest } },
|
||||
});
|
||||
expect(kept.map((task) => task.status)).toEqual(['OPEN', 'OPEN']);
|
||||
});
|
||||
|
||||
it('403 without tasks:complete when one task is DONE, and nothing changes', async () => {
|
||||
const ended = await endColony('forbidden');
|
||||
const [first] = [...ended.taskIds].sort();
|
||||
|
||||
await applyCleanup(tokens.noComplete, ended.suggestionId, {
|
||||
tasks: [{ id: first, outcome: 'DONE' }],
|
||||
}).expect(403);
|
||||
|
||||
await expect(
|
||||
prisma.task.findUniqueOrThrow({ where: { id: first } }),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
await expect(
|
||||
prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: ended.suggestionId },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
});
|
||||
|
||||
it('400 for a task id that is not in the evidence', async () => {
|
||||
const ended = await endColony('foreign');
|
||||
const other = await newColony('foreign-other', 1);
|
||||
|
||||
const response = await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [{ id: other.taskIds[0], outcome: 'CANCEL' }],
|
||||
}).expect(400);
|
||||
|
||||
expect((response.body as { message: string }).message).toBe(
|
||||
'Nur Aufgaben und Königin aus dem Vorschlag.',
|
||||
);
|
||||
await expect(
|
||||
prisma.task.findUniqueOrThrow({ where: { id: other.taskIds[0] } }),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
});
|
||||
|
||||
it('409 when the suggestion was cleaned up already', async () => {
|
||||
const ended = await endColony('twice');
|
||||
|
||||
await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [],
|
||||
}).expect(201);
|
||||
const second = await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [],
|
||||
}).expect(409);
|
||||
|
||||
expect(second.body).toMatchObject({
|
||||
message: 'Der Vorschlag ist nicht mehr offen.',
|
||||
status: 'RESOLVED',
|
||||
});
|
||||
});
|
||||
|
||||
it('403 without queens:update when the queen is given, and nothing changes', async () => {
|
||||
const ended = await endColony('noqueens');
|
||||
|
||||
await applyCleanup(tokens.noQueens, ended.suggestionId, {
|
||||
tasks: [],
|
||||
queen: { id: ended.queenId, status: 'SOLD' },
|
||||
}).expect(403);
|
||||
|
||||
await expect(
|
||||
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('400 for a queen id that is not in the evidence', async () => {
|
||||
const ended = await endColony('foreignqueen');
|
||||
const other = await newColony('foreignqueen-other', 0);
|
||||
|
||||
await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [],
|
||||
queen: { id: other.queenId, status: 'SOLD' },
|
||||
}).expect(400);
|
||||
|
||||
await expect(
|
||||
prisma.queen.findUniqueOrThrow({ where: { id: other.queenId } }),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('400 when the evidence has no queen but the request names one', async () => {
|
||||
const colony = await newColony('noqueenevidence');
|
||||
await prisma.queen.update({
|
||||
where: { id: colony.queenId },
|
||||
data: { status: 'LOST' },
|
||||
});
|
||||
await api()
|
||||
.patch(`/api/v1/colonies/${colony.colonyId}`)
|
||||
.set(auth(tokens.full))
|
||||
.send({ status: 'SOLD' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [colony.colonyId]);
|
||||
const suggestion = await prisma.suggestion.findFirstOrThrow({
|
||||
where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' },
|
||||
});
|
||||
expect((suggestion.evidence as { queenId: unknown }).queenId).toBeNull();
|
||||
|
||||
await applyCleanup(tokens.full, suggestion.id, {
|
||||
tasks: [],
|
||||
queen: { id: colony.queenId, status: 'SOLD' },
|
||||
}).expect(400);
|
||||
});
|
||||
|
||||
it('409 when a colony holds the queen again, and the whole clean-up rolls back', async () => {
|
||||
const ended = await endColony('held');
|
||||
const holder = await newColony('held-holder', 0);
|
||||
await prisma.colony.update({
|
||||
where: { id: holder.colonyId },
|
||||
data: { currentQueenId: ended.queenId },
|
||||
});
|
||||
const [first] = [...ended.taskIds].sort();
|
||||
|
||||
await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [{ id: first, outcome: 'CANCEL' }],
|
||||
queen: { id: ended.queenId, status: 'DECEASED' },
|
||||
}).expect(409);
|
||||
|
||||
await expect(
|
||||
prisma.task.findUniqueOrThrow({ where: { id: first } }),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
await expect(
|
||||
prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: ended.suggestionId },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
await expect(
|
||||
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
|
||||
).resolves.toMatchObject({ status: 'ACTIVE' });
|
||||
});
|
||||
|
||||
it('reactivating the colony turns its open CLEANUP suggestion OBSOLETE', async () => {
|
||||
const ended = await endColony('reactivate');
|
||||
|
||||
await api()
|
||||
.post(`/api/v1/colonies/${ended.colonyId}/reactivate`)
|
||||
.set(auth(tokens.full))
|
||||
.expect(201);
|
||||
|
||||
await expect(
|
||||
prisma.suggestion.findUniqueOrThrow({
|
||||
where: { id: ended.suggestionId },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'OBSOLETE' });
|
||||
});
|
||||
|
||||
it('409 "Das Volk ist wieder aktiv." when the colony lives again but the suggestion is still open', async () => {
|
||||
const ended = await endColony('living');
|
||||
await prisma.colony.update({
|
||||
where: { id: ended.colonyId },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
const [first] = [...ended.taskIds].sort();
|
||||
|
||||
const response = await applyCleanup(tokens.full, ended.suggestionId, {
|
||||
tasks: [{ id: first, outcome: 'CANCEL' }],
|
||||
}).expect(409);
|
||||
|
||||
expect((response.body as { message: string }).message).toBe(
|
||||
'Das Volk ist wieder aktiv.',
|
||||
);
|
||||
await expect(
|
||||
prisma.task.findUniqueOrThrow({ where: { id: first } }),
|
||||
).resolves.toMatchObject({ status: 'OPEN' });
|
||||
});
|
||||
|
||||
it('end, reactivate, end again the same day leaves one OBSOLETE and one OPEN CLEANUP suggestion', async () => {
|
||||
const ended = await endColony('reend');
|
||||
|
||||
await api()
|
||||
.post(`/api/v1/colonies/${ended.colonyId}/reactivate`)
|
||||
.set(auth(tokens.full))
|
||||
.expect(201);
|
||||
await api()
|
||||
.patch(`/api/v1/colonies/${ended.colonyId}`)
|
||||
.set(auth(tokens.full))
|
||||
.send({ status: 'DECEASED' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [ended.colonyId]);
|
||||
|
||||
const cleanups = await prisma.suggestion.findMany({
|
||||
where: { colonyId: ended.colonyId, intent: 'CLEANUP' },
|
||||
});
|
||||
expect(cleanups.map((row) => row.status).sort()).toEqual([
|
||||
'OBSOLETE',
|
||||
'OPEN',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ interface HarvestBody {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
colonies: { colonyId: string }[];
|
||||
warnings: unknown[];
|
||||
}
|
||||
|
||||
describe('Harvests (e2e)', () => {
|
||||
@@ -133,6 +134,8 @@ describe('Harvests (e2e)', () => {
|
||||
const harvest = response.body as HarvestBody;
|
||||
harvestId = harvest.id;
|
||||
expect(harvest.colonies).toHaveLength(2);
|
||||
// No treatment with a withdrawal period: the list is there, and empty.
|
||||
expect(harvest.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => {
|
||||
@@ -202,11 +205,12 @@ describe('Harvests (e2e)', () => {
|
||||
});
|
||||
|
||||
it('records HarvestCorrected with the new colony set on an edit', async () => {
|
||||
await request(app.getHttpServer())
|
||||
const edited = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/harvests/${harvestId}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ colonyIds: [colonyOneId] })
|
||||
.expect(200);
|
||||
expect((edited.body as HarvestBody).warnings).toEqual([]);
|
||||
|
||||
const corrections = await prisma.domainEvent.findMany({
|
||||
where: {
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('Inspections (e2e)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => {
|
||||
it('records InspectionCorrected for an edit, whose follow-ups are the autopilot, the rules and the calendar', async () => {
|
||||
await drainDomainEvents(app, [inspectionId]);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
@@ -189,13 +189,18 @@ describe('Inspections (e2e)', () => {
|
||||
});
|
||||
|
||||
await drainDomainEvents(app, [inspectionId]);
|
||||
// A correction evaluates no automation rule and sends no webhook; the
|
||||
// calendar and the autopilot of the colony follow.
|
||||
// A correction proposes nothing and sends no webhook; the autopilot, the
|
||||
// automation rules (they only follow open suggestions) and the calendar
|
||||
// follow.
|
||||
const deliveries = await prisma.domainEventDelivery.findMany({
|
||||
where: { eventId: correction.id },
|
||||
orderBy: { handler: 'asc' },
|
||||
});
|
||||
expect(deliveries).toEqual([
|
||||
expect.objectContaining({
|
||||
handler: 'automation.inspection-rules',
|
||||
status: 'DONE',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
handler: 'autopilot.colony-sync',
|
||||
status: 'DONE',
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import type { Prisma } from '../generated/prisma';
|
||||
import { NextStepsService } from '../src/autopilot/next-steps.service';
|
||||
import { addDays, berlinToday } from '../src/common/dates';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { login } from './utils/api';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
/** The example of Spec 8.5: colony B3 on 12 May, in the swarm season by calendar. */
|
||||
const EXAMPLE_DAY = '2026-05-12';
|
||||
|
||||
interface StepBody {
|
||||
key: string;
|
||||
kind: string;
|
||||
intent: string | null;
|
||||
title: string;
|
||||
reasons: string[];
|
||||
dueOn: string | null;
|
||||
urgency: string;
|
||||
action: { type: string; form?: string; href?: string; suggestionId?: string };
|
||||
dismissible: boolean;
|
||||
}
|
||||
|
||||
interface NextStepsBody {
|
||||
colonyId: string;
|
||||
segment: string;
|
||||
inspectionDueOn: string | null;
|
||||
withdrawalUntil: string | null;
|
||||
steps: StepBody[];
|
||||
}
|
||||
|
||||
interface TodayBody {
|
||||
nextStep: { colonyId: string } | null;
|
||||
inspectionDue: { apiaryId: string }[];
|
||||
suggestionsOpen: number;
|
||||
seasons: { apiaryId: string; segment: string }[];
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next steps and the day overview against a real PostgreSQL. The
|
||||
* assertions about order use the service with the fixed example day; the
|
||||
* routes are checked for what the user may see, which does not depend on the
|
||||
* day. Everything is scoped to this file's own apiary and colonies.
|
||||
*/
|
||||
describe('Next steps (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let nextSteps: NextStepsService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
const FULL = [
|
||||
'colonies:read',
|
||||
'tasks:read',
|
||||
'tasks:create',
|
||||
'inspections:read',
|
||||
'varroa:read',
|
||||
'treatments:read',
|
||||
];
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let recorderId: string;
|
||||
const roleIds: string[] = [];
|
||||
const userIds: string[] = [];
|
||||
const users = {} as Record<
|
||||
'full' | 'noVarroa' | 'noCreate' | 'noTasks',
|
||||
{ id: string; token: string }
|
||||
>;
|
||||
|
||||
async function createUser(
|
||||
label: string,
|
||||
permissions: string[],
|
||||
): Promise<{ id: string; token: string }> {
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `NEXT_STEPS_${label.toUpperCase()}_${suffix}`,
|
||||
name: `Next steps ${label}`,
|
||||
permissions: {
|
||||
create: permissions.map((key) => ({
|
||||
permission: { connect: { key } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleIds.push(role.id);
|
||||
const email = `next-steps-${label}-${suffix}@example.com`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: `Next steps ${label}`,
|
||||
role: { connect: { id: role.id } },
|
||||
},
|
||||
});
|
||||
userIds.push(user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
token: (await login(app, email, password)).accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
function suggestion(
|
||||
overrides: Partial<Prisma.SuggestionUncheckedCreateInput>,
|
||||
): Prisma.SuggestionUncheckedCreateInput {
|
||||
return {
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
source: 'RULE',
|
||||
mode: 'DO',
|
||||
intent: 'INSPECT',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: 'NEXT_STEPS_E2E',
|
||||
evidence: { colonyId },
|
||||
requiredPermission: 'inspections:read',
|
||||
dueDate: d(EXAMPLE_DAY),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The three suggestions of the spec example. */
|
||||
const exampleSuggestions = (): Prisma.SuggestionUncheckedCreateInput[] => [
|
||||
suggestion({
|
||||
source: 'AUTOPILOT',
|
||||
mode: 'DO',
|
||||
intent: 'INSPECT',
|
||||
priority: 'HIGH',
|
||||
reasonCode: 'INSPECTION_DUE',
|
||||
evidence: {
|
||||
colonyId,
|
||||
inspectionId: 'none',
|
||||
lastInspectedOn: '2026-05-04',
|
||||
dueOn: '2026-05-11',
|
||||
intervalDays: 7,
|
||||
segment: 'SCHWARMZEIT',
|
||||
},
|
||||
dueDate: d('2026-05-11'),
|
||||
}),
|
||||
suggestion({
|
||||
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
source: 'CHAIN',
|
||||
mode: 'PLAN',
|
||||
intent: 'SWARM_CHECK',
|
||||
reasonCode: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
evidence: {
|
||||
colonyId,
|
||||
inspectionId: 'insp-1',
|
||||
inspectedOn: '2026-05-05',
|
||||
baseDate: '2026-05-05',
|
||||
},
|
||||
}),
|
||||
suggestion({
|
||||
ruleKey: 'VARROA_THRESHOLD',
|
||||
mode: 'PLAN',
|
||||
intent: 'VARROA_TREAT',
|
||||
reasonCode: 'VARROA_RED',
|
||||
evidence: {
|
||||
colonyId,
|
||||
measurementId: 'm-1',
|
||||
measuredOn: '2026-05-10',
|
||||
value: 12.4,
|
||||
redAbove: 10,
|
||||
unit: 'MITES_PER_DAY',
|
||||
},
|
||||
requiredPermission: 'varroa:read',
|
||||
}),
|
||||
suggestion({
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
source: 'AUTOPILOT',
|
||||
intent: 'HONEY_SUPER',
|
||||
reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER',
|
||||
dueDate: d('2026-05-14'),
|
||||
}),
|
||||
];
|
||||
|
||||
const get = (token: string, path: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/api/v1${path}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
const cleanDb = async () => {
|
||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||
await prisma.task.deleteMany({
|
||||
where: { relatedType: 'COLONY', relatedId: colonyId },
|
||||
});
|
||||
await prisma.treatment.deleteMany({ where: { colonyId } });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
nextSteps = app.get(NextStepsService);
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Next steps ${suffix}` } })
|
||||
).id;
|
||||
colonyId = (
|
||||
await prisma.colony.create({
|
||||
data: {
|
||||
code: `B3-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
createdAt: d('2026-04-01'),
|
||||
},
|
||||
})
|
||||
).id;
|
||||
users.full = await createUser('full', FULL);
|
||||
users.noVarroa = await createUser(
|
||||
'novarroa',
|
||||
FULL.filter((key) => key !== 'varroa:read'),
|
||||
);
|
||||
users.noCreate = await createUser(
|
||||
'nocreate',
|
||||
FULL.filter((key) => key !== 'tasks:create'),
|
||||
);
|
||||
users.noTasks = await createUser(
|
||||
'notasks',
|
||||
FULL.filter((key) => !key.startsWith('tasks:')),
|
||||
);
|
||||
recorderId = users.full.id;
|
||||
await prisma.inspection.create({
|
||||
data: { colonyId, userId: recorderId, date: d('2026-05-04') },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(cleanDb);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDb();
|
||||
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||
await prisma.colony.delete({ where: { id: colonyId } });
|
||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns the three steps of the spec example in order', async () => {
|
||||
await prisma.suggestion.createMany({ data: exampleSuggestions() });
|
||||
|
||||
const view = await nextSteps.forColony(
|
||||
colonyId,
|
||||
users.full.id,
|
||||
EXAMPLE_DAY,
|
||||
);
|
||||
|
||||
expect(view).toMatchObject({
|
||||
colonyId,
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
inspectionDueOn: '2026-05-11',
|
||||
withdrawalUntil: null,
|
||||
});
|
||||
expect(view.steps).toHaveLength(3);
|
||||
const [inspection, treatment, honey] = view.steps;
|
||||
expect(inspection).toMatchObject({
|
||||
title: 'Durchsicht mit Schwarmkontrolle',
|
||||
urgency: 'OVERDUE',
|
||||
dueOn: '2026-05-11',
|
||||
action: {
|
||||
type: 'CAPTURE',
|
||||
form: 'inspection',
|
||||
href: `/inspections/new?colonyId=${colonyId}`,
|
||||
},
|
||||
dismissible: true,
|
||||
});
|
||||
expect(inspection.reasons).toHaveLength(2);
|
||||
expect(inspection.reasons[0]).toContain('Weiselzellen');
|
||||
expect(inspection.reasons[1]).toContain('Letzte Durchsicht vor 8 Tagen');
|
||||
expect(treatment).toMatchObject({
|
||||
intent: 'VARROA_TREAT',
|
||||
action: { type: 'ACCEPT' },
|
||||
dismissible: true,
|
||||
});
|
||||
expect(treatment.reasons.join(' ')).toContain('12,4');
|
||||
expect(honey).toMatchObject({
|
||||
intent: 'HONEY_SUPER',
|
||||
title: 'Honigraum aufsetzen',
|
||||
urgency: 'SOON',
|
||||
action: { type: 'CAPTURE', form: 'inspection' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a HONEY_SUPER PLAN suggestion stays a step of its own next to the merged inspection', async () => {
|
||||
await prisma.suggestion.createMany({
|
||||
data: [
|
||||
...exampleSuggestions().filter((row) => row.intent !== 'HONEY_SUPER'),
|
||||
suggestion({
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
source: 'AUTOPILOT',
|
||||
mode: 'PLAN',
|
||||
intent: 'HONEY_SUPER',
|
||||
reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER',
|
||||
dueDate: d('2026-05-14'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = await nextSteps.forColony(
|
||||
colonyId,
|
||||
users.full.id,
|
||||
EXAMPLE_DAY,
|
||||
);
|
||||
|
||||
const titles = view.steps.map((step) => step.title);
|
||||
expect(titles).toContain('Durchsicht mit Schwarmkontrolle');
|
||||
expect(view.steps.map((step) => step.intent)).toContain('HONEY_SUPER');
|
||||
const merged = view.steps.find((step) =>
|
||||
step.title.startsWith('Durchsicht'),
|
||||
);
|
||||
expect(merged?.suggestionIds).toHaveLength(2);
|
||||
expect(merged?.dismissible).toBe(true);
|
||||
});
|
||||
|
||||
it('next steps never carry a varroa reason without varroa:read', async () => {
|
||||
await prisma.suggestion.createMany({ data: exampleSuggestions() });
|
||||
|
||||
const full = await get(
|
||||
users.full.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
const restricted = await get(
|
||||
users.noVarroa.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
|
||||
const fullBody = full.body as NextStepsBody;
|
||||
const restrictedBody = restricted.body as NextStepsBody;
|
||||
expect(fullBody.steps.map((step) => step.intent)).toContain('VARROA_TREAT');
|
||||
expect(restrictedBody.steps.map((step) => step.intent)).not.toContain(
|
||||
'VARROA_TREAT',
|
||||
);
|
||||
expect(JSON.stringify(restrictedBody)).not.toMatch(/Milben|Behandlung/);
|
||||
});
|
||||
|
||||
it('a user without tasks:read gets no suggestion steps and no ACCEPT action', async () => {
|
||||
await prisma.suggestion.createMany({ data: exampleSuggestions() });
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
title: 'Wachs einschmelzen',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
},
|
||||
});
|
||||
|
||||
const noTasks = await get(
|
||||
users.noTasks.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
const noCreate = await get(
|
||||
users.noCreate.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
|
||||
const hidden = noTasks.body as NextStepsBody;
|
||||
expect(hidden.steps).toEqual([]);
|
||||
// The inspection date is not a task thing: it stays.
|
||||
expect(hidden.segment).toEqual(expect.any(String));
|
||||
const limited = noCreate.body as NextStepsBody;
|
||||
expect(limited.steps.length).toBeGreaterThan(0);
|
||||
expect(limited.steps.map((step) => step.action.type)).not.toContain(
|
||||
'ACCEPT',
|
||||
);
|
||||
expect(
|
||||
limited.steps.find((step) => step.intent === 'VARROA_TREAT')?.action,
|
||||
).toMatchObject({ type: 'CAPTURE', form: 'treatment' });
|
||||
});
|
||||
|
||||
it('withdrawalUntil appears as a hint, not as a step', async () => {
|
||||
const today = berlinToday();
|
||||
await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId: recorderId,
|
||||
date: d(addDays(today, -3)),
|
||||
product: 'Oxalsäure',
|
||||
withdrawalPeriodDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const full = await get(
|
||||
users.full.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
const readerWithoutTasks = await get(
|
||||
users.noTasks.token,
|
||||
`/colonies/${colonyId}/next-steps`,
|
||||
).expect(200);
|
||||
|
||||
const body = full.body as NextStepsBody;
|
||||
expect(body.withdrawalUntil).toBe(addDays(today, 27));
|
||||
expect(body.steps).toEqual([]);
|
||||
expect((readerWithoutTasks.body as NextStepsBody).withdrawalUntil).toBe(
|
||||
addDays(today, 27),
|
||||
);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown colony and 403 without colonies:read', async () => {
|
||||
await get(users.full.token, '/colonies/does-not-exist/next-steps').expect(
|
||||
404,
|
||||
);
|
||||
const noColonies = await createUser('nocolonies', ['tasks:read']);
|
||||
await get(noColonies.token, `/colonies/${colonyId}/next-steps`).expect(403);
|
||||
await get(noColonies.token, '/autopilot/today').expect(403);
|
||||
});
|
||||
|
||||
it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', async () => {
|
||||
await prisma.suggestion.createMany({ data: exampleSuggestions() });
|
||||
|
||||
const full = await nextSteps.today(users.full.id, EXAMPLE_DAY);
|
||||
const entry = full.inspectionDue.find((item) => item.apiaryId === apiaryId);
|
||||
expect(entry).toMatchObject({
|
||||
apiaryName: `Next steps ${suffix}`,
|
||||
segment: 'SCHWARMZEIT',
|
||||
intervalDays: 7,
|
||||
colonies: [
|
||||
{
|
||||
id: colonyId,
|
||||
code: `B3-${suffix}`,
|
||||
dueOn: '2026-05-11',
|
||||
overdueDays: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(full.seasons).toContainEqual(
|
||||
expect.objectContaining({ apiaryId, segment: 'SCHWARMZEIT' }),
|
||||
);
|
||||
expect(full.suggestionsOpen).toBeGreaterThanOrEqual(4);
|
||||
expect(full.tasksVisible).toBe(true);
|
||||
|
||||
const noTasks = await get(users.noTasks.token, '/autopilot/today').expect(
|
||||
200,
|
||||
);
|
||||
const hidden = noTasks.body as TodayBody;
|
||||
expect(hidden).toMatchObject({
|
||||
nextStep: null,
|
||||
suggestionsOpen: 0,
|
||||
tasksVisible: false,
|
||||
});
|
||||
expect(hidden.seasons.map((season) => season.apiaryId)).toContain(apiaryId);
|
||||
|
||||
const allowed = await get(users.full.token, '/autopilot/today').expect(200);
|
||||
const body = allowed.body as TodayBody;
|
||||
expect(body.tasksVisible).toBe(true);
|
||||
expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4);
|
||||
expect(body.nextStep).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,35 @@ describe('Notifications (e2e)', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('deleting a user removes their notifications and settings', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `notifications-e2e-cascade-${suffix}@example.com`,
|
||||
passwordHash: 'not-used',
|
||||
name: 'Cascade',
|
||||
role: { connect: { key: roleKey } },
|
||||
},
|
||||
});
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'weg',
|
||||
},
|
||||
});
|
||||
await prisma.notificationPreference.create({ data: { userId: user.id } });
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
expect(
|
||||
await prisma.notification.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prisma.notificationPreference.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reading is per user: marking one copy read leaves the other unread', async () => {
|
||||
const [first, second] = await notifications.recordForUsers(
|
||||
[ids.beekeeper, ids.staff],
|
||||
|
||||
@@ -203,7 +203,12 @@ describe('Winter checks (e2e)', () => {
|
||||
'ColonyStatusChanged',
|
||||
'ColonyEnded',
|
||||
]);
|
||||
expect(events[1].payload).toEqual({ status: 'DECEASED' });
|
||||
expect(events[1].payload).toEqual({
|
||||
status: 'DECEASED',
|
||||
endedOn: '2002-03-20',
|
||||
releasedQueenId: null,
|
||||
via: 'WINTER_CHECK',
|
||||
});
|
||||
expect(
|
||||
await prisma.domainEvent.count({ where: { aggregateId: survivorId } }),
|
||||
).toBe(0);
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
||||
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
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 Warning {
|
||||
code: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
refs: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
interface WithWarningsBody {
|
||||
id: string;
|
||||
warnings: Warning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The withdrawal (Wartezeit) chain against a real PostgreSQL: a harvest or
|
||||
* a bottling inside a treatment's window is written and answered with a
|
||||
* warning, leaves one notification per harvest and colony, the end of a
|
||||
* window tells that the colony may be harvested again, and the window
|
||||
* reaches the M365 calendar as a span. The notifications fan out to every
|
||||
* user holding the permissions, so the assertions only look at the reader
|
||||
* created here and every block removes the rows it caused.
|
||||
*/
|
||||
describe('Withdrawal chain (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let events: DomainEventsService;
|
||||
let calendarSync: CalendarSyncService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const password = 'SuperSecret123!';
|
||||
let apiaryId: string;
|
||||
let adminId: string;
|
||||
let readerId: string;
|
||||
let roleId: string;
|
||||
|
||||
const colonyIds: string[] = [];
|
||||
const harvestIds: string[] = [];
|
||||
const treatmentIds: string[] = [];
|
||||
const bottlingIds: string[] = [];
|
||||
const extractionIds: string[] = [];
|
||||
const honeyBatchIds: string[] = [];
|
||||
const rootIds: string[] = [];
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
const asAdmin = { Authorization: '' };
|
||||
|
||||
async function colonyWith(
|
||||
label: string,
|
||||
): Promise<{ id: string; code: string }> {
|
||||
const colony = await prisma.colony.create({
|
||||
data: {
|
||||
code: `WD-${label}-${suffix}`,
|
||||
apiaryId,
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
},
|
||||
});
|
||||
colonyIds.push(colony.id);
|
||||
return { id: colony.id, code: colony.code };
|
||||
}
|
||||
|
||||
async function treat(
|
||||
colonyId: string,
|
||||
day: string,
|
||||
withdrawalPeriodDays: number | null,
|
||||
): Promise<string> {
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId: adminId,
|
||||
date: new Date(day.includes('T') ? day : `${day}T00:00:00.000Z`),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays,
|
||||
},
|
||||
});
|
||||
treatmentIds.push(treatment.id);
|
||||
return treatment.id;
|
||||
}
|
||||
|
||||
async function postHarvest(colonyId: string | string[], day: string) {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/harvests')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
apiaryId,
|
||||
date: `${day}T00:00:00.000Z`,
|
||||
colonyIds: Array.isArray(colonyId) ? colonyId : [colonyId],
|
||||
})
|
||||
.expect(201);
|
||||
const body = response.body as WithWarningsBody;
|
||||
harvestIds.push(body.id);
|
||||
rootIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
const conflictRowsOf = (harvestId: string, colonyId: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: readerId,
|
||||
dedupeKey: {
|
||||
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Removes what this file's chain wrote to anybody, so other files' users stay clean. */
|
||||
async function purgeNotifications(): Promise<void> {
|
||||
await prisma.notification.deleteMany({
|
||||
where: {
|
||||
category: 'WITHDRAWAL',
|
||||
OR: [
|
||||
{ relatedId: { in: [...harvestIds, ...colonyIds] } },
|
||||
...treatmentIds.map((id) => ({
|
||||
dedupeKey: { startsWith: `withdrawal-ended:${id}:` },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
events = app.get(DomainEventsService);
|
||||
calendarSync = app.get(CalendarSyncService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: `withdrawal-e2e-admin-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Withdrawal admin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
adminId = admin.id;
|
||||
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
|
||||
const role = await prisma.role.create({
|
||||
data: {
|
||||
key: `WD_READER_${suffix}`,
|
||||
name: 'Wartezeit Leser',
|
||||
permissions: {
|
||||
create: ['harvests:read', 'harvests:create', 'treatments:read'].map(
|
||||
(key) => ({ permission: { connect: { key } } }),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
roleId = role.id;
|
||||
const reader = await prisma.user.create({
|
||||
data: {
|
||||
email: `withdrawal-e2e-reader-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Withdrawal reader',
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
readerId = reader.id;
|
||||
apiaryId = (
|
||||
await prisma.apiary.create({ data: { name: `Wartezeit ${suffix}` } })
|
||||
).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await purgeNotifications();
|
||||
const roots = await prisma.domainEvent.findMany({
|
||||
where: { aggregateId: { in: rootIds } },
|
||||
select: { correlationId: true },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ aggregateId: { in: rootIds } },
|
||||
{ correlationId: { in: roots.map((root) => root.correlationId) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||
where: {
|
||||
sourceId: { in: [...treatmentIds, ...harvestIds] },
|
||||
},
|
||||
});
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ relatedId: { in: colonyIds } },
|
||||
{ sourceSuggestion: { colonyId: { in: colonyIds } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.suggestion.deleteMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: { in: [adminId, readerId] } },
|
||||
{
|
||||
entityId: {
|
||||
in: [...harvestIds, ...bottlingIds, ...treatmentIds],
|
||||
},
|
||||
},
|
||||
...colonyIds.map((colonyId) => ({
|
||||
entityType: 'Suggestion',
|
||||
afterData: { path: ['colonyId'], equals: colonyId },
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.bottlingBatch.deleteMany({
|
||||
where: { honeyBatchId: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.honeyBatch.deleteMany({
|
||||
where: { id: { in: honeyBatchIds } },
|
||||
});
|
||||
await prisma.extractionHarvest.deleteMany({
|
||||
where: { extractionId: { in: extractionIds } },
|
||||
});
|
||||
await prisma.extraction.deleteMany({
|
||||
where: { id: { in: extractionIds } },
|
||||
});
|
||||
await prisma.harvestColony.deleteMany({
|
||||
where: { harvestId: { in: harvestIds } },
|
||||
});
|
||||
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
|
||||
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
|
||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: [adminId, readerId] } },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [adminId, readerId] } },
|
||||
});
|
||||
await prisma.role.deleteMany({ where: { id: roleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('harvest and bottling inside the window', () => {
|
||||
let colony: { id: string; code: string };
|
||||
let harvestId: string;
|
||||
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
beforeAll(async () => {
|
||||
colony = await colonyWith('A');
|
||||
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
|
||||
await treat(colony.id, '2026-07-10', 20);
|
||||
});
|
||||
|
||||
it('a harvest inside the window answers 201 with a warning and leaves one notification', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-07-20');
|
||||
harvestId = harvest.id;
|
||||
|
||||
expect(harvest.warnings).toEqual([
|
||||
{
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
severity: 'WARNUNG',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
refs: [
|
||||
{ type: 'Colony', id: colony.id },
|
||||
{ type: 'Treatment', id: treatmentIds[0] },
|
||||
{ type: 'Harvest', id: harvestId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
// The request wrote the harvest all the same.
|
||||
expect(await prisma.harvest.count({ where: { id: harvestId } })).toBe(1);
|
||||
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
|
||||
const rows = await conflictRowsOf(harvestId, colony.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'WARNUNG',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
relatedType: 'Harvest',
|
||||
relatedId: harvestId,
|
||||
link: `/harvests/${harvestId}`,
|
||||
read: false,
|
||||
});
|
||||
expect(rows[0].requiredPermissions).toEqual(
|
||||
expect.arrayContaining(['harvests:read', 'treatments:read']),
|
||||
);
|
||||
});
|
||||
|
||||
it('a bottling of that harvest warns again but notifies no second time', async () => {
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
|
||||
});
|
||||
extractionIds.push(extraction.id);
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId: extraction.id, harvestId },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `WD-HC-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 20,
|
||||
},
|
||||
});
|
||||
honeyBatchIds.push(honeyBatch.id);
|
||||
await drainDomainEvents(app, [harvestId]);
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
|
||||
const response = await request(server())
|
||||
.post('/api/v1/bottling-batches')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
honeyBatchId: honeyBatch.id,
|
||||
date: '2026-07-22T00:00:00.000Z',
|
||||
jarSize: 500,
|
||||
jarCount: 4,
|
||||
bestBeforeDate: '2028-07-22T00:00:00.000Z',
|
||||
lotNumber: `WD-LOT-${suffix}`,
|
||||
})
|
||||
.expect(201);
|
||||
const bottling = response.body as WithWarningsBody;
|
||||
bottlingIds.push(bottling.id);
|
||||
rootIds.push(bottling.id);
|
||||
|
||||
expect(bottling.warnings).toHaveLength(1);
|
||||
expect(bottling.warnings[0]).toMatchObject({
|
||||
code: 'WITHDRAWAL_ACTIVE',
|
||||
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
|
||||
});
|
||||
|
||||
await drainDomainEvents(app, [bottling.id]);
|
||||
|
||||
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a harvest on the first harvestable day answers an empty warning list', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-07-30');
|
||||
|
||||
expect(harvest.warnings).toEqual([]);
|
||||
await drainDomainEvents(app, [harvest.id]);
|
||||
expect(await conflictRowsOf(harvest.id, colony.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a corrected harvest that moves out of the window answers an empty list; into it, a warning', async () => {
|
||||
const harvest = await postHarvest(colony.id, '2026-09-01');
|
||||
expect(harvest.warnings).toEqual([]);
|
||||
|
||||
const moved = await request(server())
|
||||
.patch(`/api/v1/harvests/${harvest.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ date: '2026-07-12T00:00:00.000Z' })
|
||||
.expect(200);
|
||||
expect((moved.body as WithWarningsBody).warnings).toHaveLength(1);
|
||||
|
||||
const back = await request(server())
|
||||
.patch(`/api/v1/harvests/${harvest.id}`)
|
||||
.set(asAdmin)
|
||||
.send({ date: '2026-09-02T00:00:00.000Z' })
|
||||
.expect(200);
|
||||
expect((back.body as WithWarningsBody).warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('harvest of several colonies', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('warns and notifies only for the colony that is inside a window', async () => {
|
||||
const inside = await colonyWith('D1');
|
||||
const outside = await colonyWith('D2');
|
||||
await treat(inside.id, '2026-07-10', 20);
|
||||
|
||||
const harvest = await postHarvest([inside.id, outside.id], '2026-07-20');
|
||||
|
||||
expect(harvest.warnings).toHaveLength(1);
|
||||
expect(harvest.warnings[0].refs).toContainEqual({
|
||||
type: 'Colony',
|
||||
id: inside.id,
|
||||
});
|
||||
expect(harvest.warnings[0].message.startsWith(`${inside.code}:`)).toBe(
|
||||
true,
|
||||
);
|
||||
await drainDomainEvents(app, [harvest.id]);
|
||||
expect(await conflictRowsOf(harvest.id, inside.id)).toHaveLength(1);
|
||||
expect(await conflictRowsOf(harvest.id, outside.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('treatment entered after the harvest', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('a backdated treatment notifies the harvests it covers, once; a correction that extends the window adds the newly covered ones', async () => {
|
||||
const colony = await colonyWith('E');
|
||||
const early = await postHarvest(colony.id, '2026-07-20');
|
||||
const late = await postHarvest(colony.id, '2026-08-05');
|
||||
expect(early.warnings).toEqual([]);
|
||||
await drainDomainEvents(app, [early.id, late.id]);
|
||||
// The early harvest was bottled before the treatment was entered.
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
|
||||
});
|
||||
extractionIds.push(extraction.id);
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId: extraction.id, harvestId: early.id },
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `WD-HC2-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
amount: 5,
|
||||
},
|
||||
});
|
||||
honeyBatchIds.push(honeyBatch.id);
|
||||
await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: honeyBatch.id,
|
||||
userId: adminId,
|
||||
date: new Date('2026-07-22T00:00:00.000Z'),
|
||||
jarSize: 500,
|
||||
jarCount: 2,
|
||||
totalAmount: 1,
|
||||
bestBeforeDate: new Date('2028-07-22T00:00:00.000Z'),
|
||||
lotNumber: `WD-LOT2-${suffix}`,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/treatments')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
colonyId: colony.id,
|
||||
date: '2026-07-10T00:00:00.000Z',
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
})
|
||||
.expect(201);
|
||||
const treatmentId = (created.body as { id: string }).id;
|
||||
treatmentIds.push(treatmentId);
|
||||
rootIds.push(treatmentId);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
// 2026-07-10 + 20 = harvestable from 2026-07-30: only the early harvest.
|
||||
const earlyRows = await conflictRowsOf(early.id, colony.id);
|
||||
expect(earlyRows).toHaveLength(1);
|
||||
expect(earlyRows[0].message).toContain(
|
||||
`Bereits abgefüllt: Los WD-LOT2-${suffix}.`,
|
||||
);
|
||||
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(0);
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/treatments/${treatmentId}`)
|
||||
.set(asAdmin)
|
||||
.send({ withdrawalPeriodDays: 40 })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
// Until 2026-08-19: the late harvest is covered now; the early one
|
||||
// is not notified a second time.
|
||||
expect(await conflictRowsOf(early.id, colony.id)).toHaveLength(1);
|
||||
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end of a window', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
const endedEvent = (
|
||||
treatmentId: string,
|
||||
colonyId: string,
|
||||
endedOn: string,
|
||||
) =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: treatmentId,
|
||||
payload: { colonyId, endedOn },
|
||||
}),
|
||||
);
|
||||
|
||||
const readyRows = (colonyId: string) =>
|
||||
prisma.notification.findMany({
|
||||
where: {
|
||||
userId: readerId,
|
||||
relatedId: colonyId,
|
||||
category: 'WITHDRAWAL',
|
||||
dedupeKey: { startsWith: 'withdrawal-ended:' },
|
||||
},
|
||||
});
|
||||
|
||||
it('WithdrawalEnded yields one INFO only when no other window is running', async () => {
|
||||
const colony = await colonyWith('B');
|
||||
// First window: 2026-06-01 + 10 = 2026-06-11. Second: 2026-06-05 + 20 = 2026-06-25.
|
||||
const first = await treat(colony.id, '2026-06-01', 10);
|
||||
const second = await treat(colony.id, '2026-06-05', 20);
|
||||
rootIds.push(first, second);
|
||||
|
||||
await endedEvent(first, colony.id, '2026-06-11');
|
||||
await drainDomainEvents(app, [first]);
|
||||
expect(await readyRows(colony.id)).toHaveLength(0);
|
||||
|
||||
// Recorded twice: the job could deliver the same end again.
|
||||
await endedEvent(second, colony.id, '2026-06-25');
|
||||
await endedEvent(second, colony.id, '2026-06-25');
|
||||
await drainDomainEvents(app, [second]);
|
||||
|
||||
const rows = await readyRows(colony.id);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
|
||||
relatedType: 'Colony',
|
||||
link: `/colonies/${colony.id}`,
|
||||
});
|
||||
expect(rows[0].requiredPermissions).toEqual(
|
||||
expect.arrayContaining(['harvests:create', 'treatments:read']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end of a window across the Berlin day boundary', () => {
|
||||
afterAll(purgeNotifications);
|
||||
|
||||
it('takes the end day from the Berlin day of a treatment stored with a time part', async () => {
|
||||
const colony = await colonyWith('F');
|
||||
// 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin; one day later
|
||||
// the period ends on 2026-06-03 (the plain UTC day would say 06-02).
|
||||
const id = await treat(colony.id, '2026-06-01T22:30:00.000Z', 1);
|
||||
rootIds.push(id);
|
||||
const record = (endedOn: string) =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'WithdrawalEnded',
|
||||
aggregateType: 'Treatment',
|
||||
aggregateId: id,
|
||||
payload: { colonyId: colony.id, endedOn },
|
||||
}),
|
||||
);
|
||||
|
||||
await record('2026-06-02');
|
||||
await drainDomainEvents(app, [id]);
|
||||
expect(
|
||||
await prisma.notification.count({
|
||||
where: { userId: readerId, relatedId: colony.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
await record('2026-06-03');
|
||||
await drainDomainEvents(app, [id]);
|
||||
const rows = await prisma.notification.findMany({
|
||||
where: { userId: readerId, relatedId: colony.id },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].message).toContain('endete am 03.06.2026');
|
||||
expect(rows[0].message).toContain(colony.code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar entry', () => {
|
||||
// The settings spy is this file's only mock; the shared singleton row is
|
||||
// never touched.
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', async () => {
|
||||
const colony = await colonyWith('C');
|
||||
jest
|
||||
.spyOn(app.get(CalendarSyncSettingsService), 'get')
|
||||
.mockResolvedValue({
|
||||
id: 'singleton',
|
||||
enabled: true,
|
||||
groupId: 'group-e2e',
|
||||
syncTask: true,
|
||||
syncInspection: true,
|
||||
syncTreatment: true,
|
||||
syncFeeding: true,
|
||||
syncHarvest: true,
|
||||
syncQueenAssignment: true,
|
||||
syncWithdrawal: true,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const created = await request(server())
|
||||
.post('/api/v1/treatments')
|
||||
.set(asAdmin)
|
||||
.send({
|
||||
colonyId: colony.id,
|
||||
date: '2026-08-03T00:00:00.000Z',
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 20,
|
||||
})
|
||||
.expect(201);
|
||||
const treatmentId = (created.body as { id: string }).id;
|
||||
treatmentIds.push(treatmentId);
|
||||
rootIds.push(treatmentId);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
const entry = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
|
||||
where: {
|
||||
sourceType_sourceId: {
|
||||
sourceType: 'WITHDRAWAL',
|
||||
sourceId: treatmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(entry.status).toBe('PENDING');
|
||||
const buildEventInput = (id: string) =>
|
||||
(
|
||||
calendarSync as unknown as {
|
||||
buildEventInput(type: string, id: string): Promise<unknown>;
|
||||
}
|
||||
).buildEventInput('WITHDRAWAL', id);
|
||||
// Graph's end day is exclusive: the first harvestable day.
|
||||
expect(await buildEventInput(treatmentId)).toEqual({
|
||||
title: `Wartezeit ${colony.code} · Oxuvar`,
|
||||
date: new Date('2026-08-03T00:00:00.000Z'),
|
||||
endDate: new Date('2026-08-23T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await request(server())
|
||||
.patch(`/api/v1/treatments/${treatmentId}`)
|
||||
.set(asAdmin)
|
||||
.send({ withdrawalPeriodDays: null })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [treatmentId]);
|
||||
|
||||
const after = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
|
||||
where: { id: entry.id },
|
||||
});
|
||||
// Enqueued again: the worker finds no span and removes the event.
|
||||
expect(after.revision).toBeGreaterThan(entry.revision);
|
||||
expect(after.status).toBe('PENDING');
|
||||
expect(await buildEventInput(treatmentId)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user