Files
ApiCore/ApiCore-Backend/test/honey-ledger.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 21:13:23 +02:00

466 lines
14 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';
import { INVARIANT_SQL } from './utils/honey-ledger';
interface HoneyBatchBody {
id: string;
amount: number | null;
initialAmount: number | null;
status: string;
warnings: { code: string }[];
}
interface BottlingBody {
id: string;
}
interface MovementBody {
id: string;
type: string;
quantityGrams: number;
reason: string | null;
notes: string | null;
sourceBottlingBatchId: string | null;
userId: string | null;
}
interface ErrorBody {
message: string;
}
/**
* The movement book of a honey batch (spec 4.6, 7.7): every change of the
* rest is a booking, and amount = initialAmount + sum(quantity) / 1000.
* Asserts only on this file's own batches - other files share the database.
*/
describe('Honey batch movement book (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const adminEmail = `honey-ledger-admin-${suffix}@example.com`;
const staffEmail = `honey-ledger-staff-${suffix}@example.com`;
let adminId: string;
let staffId: string;
let adminToken: string;
let staffToken: string;
let extractionId: string;
const honeyBatchIds: string[] = [];
const bottlingIds: string[] = [];
const server = () => app.getHttpServer();
const admin = () => ({ Authorization: `Bearer ${adminToken}` });
async function createBatch(amount?: number): Promise<HoneyBatchBody> {
const response = await request(server())
.post('/api/v1/honey-batches')
.set(admin())
.send({
batchNumber: `HL-${suffix}-${honeyBatchIds.length}`,
extractionId,
...(amount === undefined ? {} : { amount }),
})
.expect(201);
const body = response.body as HoneyBatchBody;
honeyBatchIds.push(body.id);
return body;
}
function bottle(honeyBatchId: string, jarCount: number) {
return request(server())
.post('/api/v1/bottling-batches')
.set(admin())
.send({
honeyBatchId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
jarSize: 500,
jarCount,
lotNumber: `HL-L-${suffix}-${Math.round(Math.random() * 1e9)}`,
});
}
async function bottleOk(honeyBatchId: string, jarCount: number) {
const response = await bottle(honeyBatchId, jarCount).expect(201);
const body = response.body as BottlingBody;
bottlingIds.push(body.id);
return body;
}
async function stored(id: string) {
return prisma.honeyBatch.findUniqueOrThrow({ where: { id } });
}
async function brokenBatchIds(): Promise<string[]> {
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(INVARIANT_SQL);
return rows.map((row) => row.id);
}
async function movementsOf(id: string): Promise<MovementBody[]> {
const response = await request(server())
.get(`/api/v1/honey-batches/${id}/movements`)
.set(admin())
.expect(200);
return (response.body as { items: MovementBody[]; hasMore: boolean }).items;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
adminId = (
await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Ledger Admin',
role: { connect: { key: 'ADMIN' } },
},
})
).id;
staffId = (
await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Ledger Staff',
role: { connect: { key: 'STAFF' } },
},
})
).id;
adminToken = (await login(app, adminEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
extractionId = (
await prisma.extraction.create({
data: { date: new Date(), userId: adminId },
})
).id;
});
afterAll(async () => {
await prisma.batchMovement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.domainEvent.deleteMany({
where: {
aggregateId: { in: [...honeyBatchIds, ...bottlingIds] },
},
});
await prisma.bottlingBatch.deleteMany({
where: { id: { in: bottlingIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extraction.deleteMany({ where: { id: extractionId } });
const userIds = [adminId, staffId].filter(Boolean);
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();
});
it('keeps the invariant after create, bottle, correct and remove', async () => {
const batch = await createBatch(10);
expect(batch.amount).toBe(10);
const row = await stored(batch.id);
expect(row.initialAmount).toBe(10);
// Creating with an amount books nothing.
expect(await movementsOf(batch.id)).toEqual([]);
const bottling = await bottleOk(batch.id, 8);
expect((await stored(batch.id)).amount).toBe(6);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 5 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(5);
await request(server())
.patch(`/api/v1/bottling-batches/${bottling.id}`)
.set(admin())
.send({ jarCount: 4 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(7);
await request(server())
.delete(`/api/v1/bottling-batches/${bottling.id}`)
.set(admin())
.expect(200);
const after = await stored(batch.id);
expect(after.amount).toBe(9);
expect(after.initialAmount).toBe(10);
expect(await brokenBatchIds()).not.toContain(batch.id);
const book = await movementsOf(batch.id);
expect(
book.map((movement) => [movement.type, movement.quantityGrams]).reverse(),
).toEqual([
['BOTTLING', -4000],
['CORRECTION', -1000],
['BOTTLING', 2000],
['BOTTLING', 2000],
]);
expect(book[0]).toMatchObject({ notes: 'Abfüllung entfernt' });
});
it('two parallel bottlings over the rest: exactly one 400', async () => {
const batch = await createBatch(10);
// 12 jars of 500 g = 6 kg each, 10 kg available.
const results = await Promise.all([
bottle(batch.id, 12),
bottle(batch.id, 12),
]);
const statuses = results.map((result) => result.status).sort();
expect(statuses).toEqual([201, 400]);
for (const result of results) {
if (result.status === 201) {
bottlingIds.push((result.body as BottlingBody).id);
}
}
expect((await stored(batch.id)).amount).toBe(4);
expect(await movementsOf(batch.id)).toHaveLength(1);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('PATCH amount null with movements answers 400', async () => {
const batch = await createBatch(10);
await bottleOk(batch.id, 2);
const response = await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: null })
.expect(400);
expect((response.body as ErrorBody).message).toBe(
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
);
expect((await stored(batch.id)).amount).toBe(9);
});
it('PATCH amount null without movements takes the amount away again', async () => {
const batch = await createBatch(10);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: null })
.expect(200);
const row = await stored(batch.id);
expect(row.amount).toBeNull();
expect(row.initialAmount).toBeNull();
});
it('a batch without an amount gets one by PATCH without a booking', async () => {
const batch = await createBatch();
expect(batch.amount).toBeNull();
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 7 })
.expect(200);
const row = await stored(batch.id);
expect(row.amount).toBe(7);
expect(row.initialAmount).toBe(7);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('a batch without an amount is bottled without a booking', async () => {
const batch = await createBatch();
await bottleOk(batch.id, 2);
const row = await stored(batch.id);
expect(row.amount).toBeNull();
expect(row.status).toBe('BOTTLED');
expect(await movementsOf(batch.id)).toEqual([]);
});
it('a bottling made before the amount was recorded never changes the ledger', async () => {
const batch = await createBatch();
const untracked = await bottleOk(batch.id, 4);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 10 })
.expect(200);
await request(server())
.patch(`/api/v1/bottling-batches/${untracked.id}`)
.set(admin())
.send({ jarCount: 2 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(10);
await request(server())
.delete(`/api/v1/bottling-batches/${untracked.id}`)
.set(admin())
.expect(200);
expect((await stored(batch.id)).amount).toBe(10);
expect(await movementsOf(batch.id)).toEqual([]);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('rounds a PATCH amount to the gram and a correction may carry a reason', async () => {
const batch = await createBatch(10);
await request(server())
.patch(`/api/v1/honey-batches/${batch.id}`)
.set(admin())
.send({ amount: 12.3456 })
.expect(200);
expect((await stored(batch.id)).amount).toBe(12.346);
const response = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: -0.346, reason: 'SONSTIGES' })
.expect(201);
expect(response.body).toMatchObject({ reason: 'SONSTIGES' });
expect((await stored(batch.id)).amount).toBe(12);
expect(await brokenBatchIds()).not.toContain(batch.id);
});
it('GET movements lists BOTTLING with its source bottling', async () => {
const batch = await createBatch(10);
const bottling = await bottleOk(batch.id, 2);
const book = await movementsOf(batch.id);
expect(book).toEqual([
expect.objectContaining({
type: 'BOTTLING',
quantityGrams: -1000,
sourceBottlingBatchId: bottling.id,
userId: adminId,
}),
]);
});
it('GET movements needs the read permission and a known batch', async () => {
await request(server())
.get('/api/v1/honey-batches/unknown/movements')
.set(admin())
.expect(404);
await request(server())
.get('/api/v1/honey-batches/unknown/movements')
.expect(401);
});
describe('POST movements', () => {
it('books a withdrawal with its reason and a correction', async () => {
const batch = await createBatch(10);
const withdrawal = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({
type: 'WITHDRAWAL',
deltaKg: -1.5,
reason: 'EIGENBEDARF',
notes: 'Für die Familie',
})
.expect(201);
expect(withdrawal.body).toMatchObject({
type: 'WITHDRAWAL',
quantityGrams: -1500,
reason: 'EIGENBEDARF',
notes: 'Für die Familie',
userId: adminId,
});
expect((await stored(batch.id)).amount).toBe(8.5);
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: 0.25, notes: 'Nachgewogen' })
.expect(201);
expect((await stored(batch.id)).amount).toBe(8.75);
expect(await brokenBatchIds()).not.toContain(batch.id);
const audit = await prisma.auditLog.findMany({
where: {
entityType: 'HoneyBatch',
entityId: batch.id,
userId: adminId,
},
});
expect(audit.length).toBeGreaterThanOrEqual(3);
});
it('refuses what would take the rest below 0, with the German message', async () => {
const batch = await createBatch(1);
const response = await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'WITHDRAWAL', deltaKg: -2, reason: 'BRUCH' })
.expect(400);
expect((response.body as ErrorBody).message).toBe(
'Die Menge kann nicht unter 0 sinken.',
);
expect((await stored(batch.id)).amount).toBe(1);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('validates the booking', async () => {
const batch = await createBatch(5);
const post = (body: object) =>
request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send(body);
await post({ type: 'WITHDRAWAL', deltaKg: -1 }).expect(400);
await post({ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' }).expect(
400,
);
await post({ type: 'CORRECTION', deltaKg: 0 }).expect(400);
await post({ type: 'CORRECTION', deltaKg: 100001 }).expect(400);
await post({ type: 'BOTTLING', deltaKg: -1 }).expect(400);
await post({
type: 'CORRECTION',
deltaKg: 1,
notes: 'x'.repeat(501),
}).expect(400);
expect(await movementsOf(batch.id)).toEqual([]);
});
it('refuses a batch whose amount is not recorded', async () => {
const batch = await createBatch();
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set(admin())
.send({ type: 'CORRECTION', deltaKg: 1 })
.expect(400);
});
it('needs the update permission', async () => {
const batch = await createBatch(5);
await request(server())
.post(`/api/v1/honey-batches/${batch.id}/movements`)
.set({ Authorization: `Bearer ${staffToken}` })
.send({ type: 'CORRECTION', deltaKg: 1 })
.expect(403);
});
});
});