Files
ApiCore/ApiCore-Backend/test/winter-checks.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 fac66882fa feat: Ein-/Auswinterung und Winterverluste je Volk
- WinterCheck per colony, winter and season (autumn/spring), every field
  editable; loss causes limited to the COLOSS monitoring categories.
- Summary per winter and apiary: loss rate lost/wintered, marked
  provisional while colonies are not yet checked in spring - an unchecked
  colony never counts as survived.
- A loss marks the colony deceased only on explicit request, in the same
  transaction and audited.
- Permissions winter-checks:read/create/update granted by migration to
  beekeeper and staff roles; winter checks included in backups.
- Page "Überwinterung": next action first, one button per row until the
  colony is wintered, German number format.

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

254 lines
7.5 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.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 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);
});
});