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]>
This commit is contained in:
1 parent
0d23963f07
commit
fac66882fa
26 files changed
+2020
-2
No files matched your search
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user