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; 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 { 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' }); 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); }); });