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; 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 { 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 { 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; 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', ]); }); });