- 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]>
274 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|