Files
ApiCore/ApiCore-Backend/test/colonies.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

435 lines
14 KiB
TypeScript

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';
import { drainDomainEvents } from './utils/domain-events';
interface ColonyBody {
id: string;
code: string;
apiaryId: string;
status: string;
}
interface ColonyHistoryBody {
apiaryHistory: unknown[];
queenHistory: unknown[];
lineageAsSource: unknown[];
lineageAsResult: unknown[];
}
describe('Colonies (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `colonies-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `colonies-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let apiaryAId: string;
let apiaryBId: string;
let colonyId: string;
let childColonyId: string;
const splitColonyIds: string[] = [];
let colonyToMergeId: string;
let healthColonyId: string;
let raceColonyId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
const apiaryA = await prisma.apiary.create({
data: { name: `Stand A ${Date.now()}` },
});
apiaryAId = apiaryA.id;
const apiaryB = await prisma.apiary.create({
data: { name: `Stand B ${Date.now()}` },
});
apiaryBId = apiaryB.id;
});
afterAll(async () => {
const colonyIds = [
colonyId,
childColonyId,
colonyToMergeId,
healthColonyId,
raceColonyId,
...splitColonyIds,
].filter(Boolean);
// Rule suggestions are written without a user - their audit entry and
// warning are not covered by the userId cleanup below.
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Suggestion', entityId: { in: suggestionIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.notification.deleteMany({
where: {
OR: [
{ relatedId: { in: colonyIds } },
{ relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
],
},
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.colonyLineage.deleteMany({
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
});
await prisma.colonyApiaryHistory.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({
where: {
id: { in: colonyIds },
},
});
await prisma.apiary.deleteMany({
where: { id: { in: [apiaryAId, apiaryBId] } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
});
await app.close();
});
it('rejects colony creation for STAFF', () => {
return request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${staffToken}`)
.send({
code: `V-${Date.now()}`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(403);
});
it('lets a BEEKEEPER create a colony', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
const created = response.body as ColonyBody;
expect(created.apiaryId).toBe(apiaryAId);
colonyId = created.id;
});
it('moves the colony to another apiary and records history', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/move`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ apiaryId: apiaryBId, notes: 'Umzug' })
.expect(201);
expect((response.body as ColonyBody).apiaryId).toBe(apiaryBId);
const history = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/history`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((history.body as ColonyHistoryBody).apiaryHistory).toHaveLength(1);
});
it('splits the colony into a new nucleus colony', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ code: `V-${Date.now()}-A`, colonyType: 'NUCLEUS_COLONY' })
.expect(201);
const child = response.body as ColonyBody;
expect(child.apiaryId).toBe(apiaryBId);
childColonyId = child.id;
const history = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/history`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1);
});
it('records ColonySplit with the given day and queen source, and refuses a future day', async () => {
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-F`,
colonyType: 'NUCLEUS_COLONY',
date: '2999-01-01',
})
.expect(400);
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-T`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05T23:30:00+02:00',
})
.expect(400);
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-B`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
})
.expect(201);
const splitId = (response.body as ColonyBody).id;
splitColonyIds.push(splitId);
const lineage = await prisma.colonyLineage.findFirstOrThrow({
where: { toColonyId: splitId },
});
expect(lineage.queenSource).toBe('QUEEN_CELL');
expect(lineage.date.toISOString().slice(0, 10)).toBe('2026-05-05');
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'ColonySplit', aggregateId: splitId },
});
expect(event.payload).toEqual({
parentColonyId: colonyId,
lineageId: lineage.id,
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
});
});
it('merges another colony into the surviving one', async () => {
const otherColony = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-M`,
apiaryId: apiaryBId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
colonyToMergeId = (otherColony.body as ColonyBody).id;
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/merge`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ mergedColonyIds: [colonyToMergeId] })
.expect(201);
const merged = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyToMergeId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((merged.body as ColonyBody).status).toBe('MERGED');
const ended = await prisma.domainEvent.findMany({
where: { type: 'ColonyEnded', aggregateId: colonyToMergeId },
});
expect(ended.map((event) => event.payload)).toEqual([
{
status: 'MERGED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'MERGE',
},
]);
});
it('dissolves the colony without deleting it', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/dissolve`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(201);
expect((response.body as ColonyBody).status).toBe('DISSOLVED');
const events = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({
status: 'DISSOLVED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'DISSOLVE',
});
await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/move`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ apiaryId: apiaryAId })
.expect(409);
});
it('reactivates a dissolved colony, and rejects reactivating an already-active one', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/reactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(201);
expect((response.body as ColonyBody).status).toBe('ACTIVE');
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/reactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
});
it('rejects reactivating a weak colony, which never ended', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'WEAK' },
});
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/reactivate`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(409);
const colony = await prisma.colony.findUniqueOrThrow({
where: { id: colonyId },
});
expect(colony.status).toBe('WEAK');
});
it('proposes the COLONY_HEALTH_CRITICAL check only after the event is processed, and only on the transition', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-H`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
healthColonyId = (created.body as ColonyBody).id;
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ healthStatus: 'CRITICAL' })
.expect(200);
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'ColonyStatusChanged', aggregateId: healthColonyId },
});
expect(event.payload).toEqual({
from: { status: 'ACTIVE', healthStatus: null },
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
});
expect(
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(0);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
// Already critical: a further edit is no transition and proposes nothing a second time.
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ notes: 'weiter kritisch' })
.expect(200);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
it('two concurrent PATCHes into CRITICAL produce one transition event and one suggestion', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-R`,
apiaryId: apiaryAId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
raceColonyId = (created.body as ColonyBody).id;
await Promise.all(
[1, 2].map(() =>
request(app.getHttpServer())
.patch(`/api/v1/colonies/${raceColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ healthStatus: 'CRITICAL' })
.expect(200),
),
);
expect(
await prisma.domainEvent.count({
where: { type: 'ColonyStatusChanged', aggregateId: raceColonyId },
}),
).toBe(1);
await drainDomainEvents(app, [raceColonyId]);
expect(
await prisma.suggestion.count({
where: { colonyId: raceColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
});