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 StockBody { summary: { initial: number; withdrawn: number; consumed: number; remaining: number; }; movements: { type: string; quantity: number; notes: string | null }[]; } interface LotBody { id: string; remainingCount: number; usedIn: { quantity: number; productionBatch: { id: string } }[]; } interface BatchBody { id: string; inputs: { quantity: number; rawMaterialLot: { id: string } }[]; } /** * Raw material lots (wax, propolis) and their consumption in production: * creating a production batch books the inputs off the raw lots in the same * transaction, never more than is left; deleting a mistaken batch gives the * material back as its own movement. */ describe('Raw materials and production (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let token: string; let waxId: string; let propolisId: string; let productId: string; let batchId: string; const server = () => app.getHttpServer(); const auth = (req: request.Test) => req.set('Authorization', `Bearer ${token}`); async function stockOf(lotId: string): Promise { const response = await auth( request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`), ).expect(200); return response.body as StockBody; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); await prisma.user.create({ data: { email: `raw-beekeeper-${suffix}@example.com`, passwordHash: await bcrypt.hash(password, 12), name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password)) .accessToken; const createLot = async (kind: string, lot: string, grams: number) => { const response = await auth( request(server()).post('/api/v1/raw-material-lots'), ) .send({ kind, lotNumber: `${lot}-${suffix}`, date: '2026-06-01', quantity: grams, }) .expect(201); return (response.body as LotBody).id; }; waxId = await createLot('BEESWAX', 'W', 1000); propolisId = await createLot('PROPOLIS', 'P', 200); const product = await prisma.product.create({ data: { name: `Propolis-Salbe ${suffix}`, kind: 'MANUFACTURED', shopCategory: 'PROPOLIS', }, }); productId = product.id; }); afterAll(async () => { await app.close(); }); it('books the raw material when a production batch is made from it', async () => { const response = await auth( request(server()).post('/api/v1/production-batches'), ) .send({ productId, lotNumber: `S-${suffix}`, date: '2026-06-10', quantity: 20, inputs: [ { rawMaterialLotId: waxId, quantity: 300 }, { rawMaterialLotId: propolisId, quantity: 50 }, ], }) .expect(201); const batch = response.body as BatchBody; batchId = batch.id; expect(batch.inputs).toHaveLength(2); expect((await stockOf(waxId)).summary).toMatchObject({ consumed: 300, remaining: 700, }); expect((await stockOf(propolisId)).summary.remaining).toBe(150); const wax = await auth( request(server()).get(`/api/v1/raw-material-lots/${waxId}`), ).expect(200); expect( (wax.body as LotBody).usedIn.map((u) => u.productionBatch.id), ).toEqual([batchId]); }); it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => { await auth(request(server()).post('/api/v1/production-batches')) .send({ productId, lotNumber: `S2-${suffix}`, date: '2026-06-11', quantity: 10, inputs: [{ rawMaterialLotId: waxId, quantity: 800 }], }) .expect(400); expect( await prisma.productionBatch.count({ where: { lotNumber: `S2-${suffix}` }, }), ).toBe(0); expect((await stockOf(waxId)).summary.remaining).toBe(700); }); it('withdraws raw material like any other stock', async () => { const response = await auth( request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`), ) .send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' }) .expect(201); expect((response.body as StockBody).summary).toMatchObject({ withdrawn: 100, consumed: 300, remaining: 600, }); }); it('gives the raw material back when a mistaken production batch is deleted', async () => { await auth( request(server()).delete(`/api/v1/production-batches/${batchId}`), ).expect(200); const wax = await stockOf(waxId); expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 }); expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe( true, ); expect((await stockOf(propolisId)).summary.remaining).toBe(200); }); it('keeps a raw lot with movements as history', async () => { await auth( request(server()).delete(`/api/v1/raw-material-lots/${waxId}`), ).expect(409); }); });