Files
ApiCore/ApiCore-Backend/test/colony-ended.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 ef33e5549b 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]>
2026-10-14 16:28:07 +02:00

587 lines
19 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { 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',
]);
});
});