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; 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 { 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 { const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(INVARIANT_SQL); return rows.map((row) => row.id); } async function movementsOf(id: string): Promise { 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); }); }); });