Files
ApiCore/ApiCore-Backend/test/colony-ended.e2e-spec.ts
T
Nick.RosenloecherandClaude Opus 5.5 81927d629a
Backend E2E / e2e (push) Successful in 5m24s
Backend Image / build-and-push (push) Successful in 5m3s
Frontend Image / build-and-push (push) Failing after 14m46s
feat(queens): verdrängte Königin bekommt ihren Endstatus automatisch
Entscheidung Inhaber: Zieht eine neue Königin ein, wird die bisherige
„Ersetzt“; Vereinigen und Auflösen eines Volks setzen dessen Königin auf
„Ersetzt“, ein Winterverlust auf „Eingegangen“. Nur aus einem verfügbaren
Status (ACTIVE, RESERVE, MATED, UNMATED), über updateStatusIn in derselben
Transaktion mit Archivdatum und Audit (QueensService.retireIn). Ein von
Hand gesetzter Endstatus eines Volks fragt weiter über den Aufräum-
Vorschlag nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 17:03:22 +02:00

637 lines
21 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',
'colonies:manage-lifecycle',
'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.colonyLineage.deleteMany({
where: { fromColonyId: { 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');
// She died with the colony and moves to the archive.
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
).resolves.toMatchObject({
status: 'DECEASED',
deactivatedAt: expect.any(Date) as Date,
});
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('dissolving a colony moves its queen to the archive as REPLACED', async () => {
const { colonyId, queenId } = await newColony('dissolve', 0);
await api()
.post(`/api/v1/colonies/${colonyId}/dissolve`)
.set(auth(tokens.full))
.expect(201);
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
).resolves.toMatchObject({
status: 'REPLACED',
deactivatedAt: expect.any(Date) as Date,
});
});
it('merging moves the queen of the merged-away colony to the archive and keeps a sold one sold', async () => {
const survivor = await newColony('survivor', 0);
const absorbed = await newColony('absorbed', 0);
const sold = await newColony('sold', 0);
await prisma.queen.update({
where: { id: sold.queenId },
data: { status: 'SOLD' },
});
await api()
.post(`/api/v1/colonies/${survivor.colonyId}/merge`)
.set(auth(tokens.full))
.send({ mergedColonyIds: [absorbed.colonyId, sold.colonyId] })
.expect(201);
const queens = await prisma.queen.findMany({
where: {
id: { in: [survivor.queenId, absorbed.queenId, sold.queenId] },
},
});
const statusOf = (id: string) =>
queens.find((queen) => queen.id === id)?.status;
expect(statusOf(survivor.queenId)).toBe('ACTIVE');
expect(statusOf(absorbed.queenId)).toBe('REPLACED');
expect(statusOf(sold.queenId)).toBe('SOLD');
});
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',
]);
});
});