Files
ApiCore/ApiCore-Backend/test/winter-checks.e2e-spec.ts
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

274 lines
8.1 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 { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface WinterCheckBody {
id: string;
colonyId: string;
winterYear: number;
season: string;
outcome: string | null;
lossCause: string | null;
}
interface WinterSummaryBody {
total: {
wintered: number;
survived: number;
lost: number;
pendingSpring: number;
lossRatePercent: number | null;
isProvisional: boolean;
};
}
// A winter far in the past so the summary only sees this spec's records.
const WINTER = 2001;
describe('Winter checks (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const stamp = Date.now();
const beekeeperEmail = `winter-e2e-beekeeper-${stamp}@example.com`;
const readonlyEmail = `winter-e2e-readonly-${stamp}@example.com`;
const password = 'SuperSecret123!';
let userIds: string[] = [];
let beekeeperToken: string;
let readonlyToken: string;
let apiaryId: string;
let survivorId: string;
let lostId: string;
let pendingId: string;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
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' } },
},
});
const readonly = await prisma.user.create({
data: {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: { connect: { key: 'READONLY' } },
},
});
userIds = [beekeeper.id, readonly.id];
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
const apiary = await prisma.apiary.create({
data: { name: `Winterstand ${stamp}` },
});
apiaryId = apiary.id;
const [survivor, lost, pending] = await Promise.all(
['A', 'B', 'C'].map((suffix) =>
prisma.colony.create({
data: {
code: `W-${stamp}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
}),
),
);
survivorId = survivor.id;
lostId = lost.id;
pendingId = pending.id;
});
afterAll(async () => {
const colonyIds = [survivorId, lostId, pendingId];
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.winterCheck.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: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
async function winterIn(colonyId: string): Promise<WinterCheckBody> {
const response = await api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({
colonyId,
season: 'AUTUMN',
date: `${WINTER}-10-01`,
occupiedSeams: 8,
feedStockKg: 18,
queenPresent: true,
})
.expect(201);
return response.body as WinterCheckBody;
}
it('records an Einwinterung and derives the winter from the date', async () => {
const check = await winterIn(survivorId);
expect(check).toMatchObject({
winterYear: WINTER,
season: 'AUTUMN',
outcome: null,
});
await winterIn(lostId);
await winterIn(pendingId);
});
it('rejects a second Einwinterung of the same colony in the same winter with 409', () => {
return api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({ colonyId: survivorId, season: 'AUTUMN', date: `${WINTER}-10-20` })
.expect(409);
});
it('rejects an Auswinterung without outcome and a loss without cause', async () => {
await api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({ colonyId: survivorId, season: 'SPRING', date: '2002-03-20' })
.expect(400);
await api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({
colonyId: lostId,
season: 'SPRING',
date: '2002-03-20',
outcome: 'LOST',
})
.expect(400);
});
it('books a loss and marks the colony deceased only when asked to', async () => {
await api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({
colonyId: survivorId,
season: 'SPRING',
date: '2002-03-20',
outcome: 'SURVIVED',
})
.expect(201);
const lost = await api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({
colonyId: lostId,
season: 'SPRING',
date: '2002-03-20',
outcome: 'LOST',
lossCause: 'DEAD_OR_EMPTY',
markColonyDeceased: true,
})
.expect(201);
expect((lost.body as WinterCheckBody).winterYear).toBe(WINTER);
const colony = await prisma.colony.findUniqueOrThrow({
where: { id: lostId },
});
expect(colony.status).toBe('DECEASED');
expect(colony.deactivatedAt?.toISOString()).toBe(
'2002-03-20T00:00:00.000Z',
);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: lostId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
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);
const survivor = await prisma.colony.findUniqueOrThrow({
where: { id: survivorId },
});
expect(survivor.status).toBe('ACTIVE');
});
it('refuses to winter a colony that has died', () => {
return api()
.post('/api/v1/winter-checks')
.set(auth(beekeeperToken))
.send({ colonyId: lostId, season: 'AUTUMN', date: '2002-10-01' })
.expect(400);
});
it('summarizes the winter, keeping unchecked colonies out of "survived"', async () => {
const response = await api()
.get(`/api/v1/winter-checks/summary?winterYear=${WINTER}`)
.set(auth(readonlyToken))
.expect(200);
expect((response.body as WinterSummaryBody).total).toEqual(
expect.objectContaining({
wintered: 3,
survived: 1,
lost: 1,
pendingSpring: 1,
lossRatePercent: 33.3,
isProvisional: true,
}),
);
});
it('keeps every field editable and clears the cause when the outcome changes', async () => {
const checks = (
await api()
.get(`/api/v1/colonies/${lostId}/winter-checks`)
.set(auth(beekeeperToken))
.expect(200)
).body as WinterCheckBody[];
const spring = checks.find((check) => check.season === 'SPRING');
const response = await api()
.patch(`/api/v1/winter-checks/${spring?.id}`)
.set(auth(beekeeperToken))
.send({ outcome: 'WEAK' })
.expect(200);
expect(response.body).toMatchObject({ outcome: 'WEAK', lossCause: null });
});
it('lets read-only users view but not record', async () => {
await api()
.get(`/api/v1/winter-checks?winterYear=${WINTER}&apiaryId=${apiaryId}`)
.set(auth(readonlyToken))
.expect(200);
await api()
.post('/api/v1/winter-checks')
.set(auth(readonlyToken))
.send({ colonyId: pendingId, season: 'SPRING', date: '2002-03-20' })
.expect(403);
});
});