import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { Prisma } from '../generated/prisma'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; import { drainDomainEvents } from './utils/domain-events'; interface StockBody { summary: { initial: number; withdrawn: number; consumed: number; remaining: number; }; movements: { id: string; 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 } }[]; } /** A shared rule's config as it was before a test changed it. */ function restoredConfig(rule: { config: Prisma.JsonValue; }): Prisma.InputJsonValue | typeof Prisma.DbNull { return rule.config === null ? Prisma.DbNull : rule.config; } /** * 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 startedAt = new Date(); const password = 'SuperSecret123!'; let token: string; let userId: string; let waxId: string; let propolisId: string; let productId: string; let batchId: string; // Tasks the RAW_MATERIAL_LOW handler created for this file. const ruleTaskIds: string[] = []; const server = () => app.getHttpServer(); const auth = (req: request.Test) => req.set('Authorization', `Bearer ${token}`); /** The StockChanged events recorded for one movement or batch. */ function stockChangedOf(aggregateId: string) { return prisma.domainEvent.findMany({ where: { type: 'StockChanged', aggregateId }, select: { aggregateType: true, payload: true, actorUserId: true }, }); } 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); const user = await prisma.user.create({ data: { email: `raw-beekeeper-${suffix}@example.com`, passwordHash: await bcrypt.hash(password, 12), name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); userId = user.id; 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 () => { // Only this file's rows, by its own ids - other e2e files share the // database. An undefined id would drop a filter, hence the guards. const lotIds = [waxId, propolisId].filter(Boolean); const movementIds = ( await prisma.batchMovement.findMany({ where: { rawMaterialLotId: { in: lotIds } }, select: { id: true }, }) ).map((movement) => movement.id); const batchIds = [batchId].filter(Boolean); // Rule tasks are written without a user: their TaskCreated, audit // entries, warning and queued deliveries go by task id. for (const taskId of ruleTaskIds) { await prisma.auditLog.deleteMany({ where: { entityType: 'AutomationRule', afterData: { path: ['taskId'], equals: taskId }, }, }); await prisma.webhookDelivery.deleteMany({ where: { event: 'task.created', body: { contains: taskId } }, }); } await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceId: { in: ruleTaskIds } }, }); // Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer // of GENERAL/PROPOLIS warnings, and only this file switches the rule on // (default: off, no minimum) - so such a warning since this file // started belongs to the task above. if (ruleTaskIds.length > 0) { await prisma.notification.deleteMany({ where: { relatedType: 'GENERAL', relatedId: 'PROPOLIS', createdAt: { gte: startedAt }, }, }); } const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds]; await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, }); await prisma.auditLog.deleteMany({ where: { entityId: { in: [...lotIds, ...aggregateIds] } }, }); await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } }); 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]); // The article for the shop, the consumed kinds for the minimum rule. const events = await stockChangedOf(batchId); expect(events).toEqual([ { aggregateType: 'ProductionBatch', payload: { bottlingBatchIds: [], productIds: [productId], rawMaterialKinds: expect.arrayContaining([ 'BEESWAX', 'PROPOLIS', ]) as unknown, }, actorUserId: userId, }, ]); expect( (events[0].payload as { rawMaterialKinds: string[] }).rawMaterialKinds, ).toHaveLength(2); }); 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('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => { const [withdrawal] = await prisma.batchMovement.findMany({ where: { rawMaterialLotId: waxId, type: 'WITHDRAWAL' }, select: { id: true }, }); expect(await stockChangedOf(withdrawal.id)).toEqual([ { aggregateType: 'BatchMovement', payload: { bottlingBatchIds: [], productIds: [], rawMaterialKinds: ['BEESWAX'], }, actorUserId: userId, }, ]); }); 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); // The give-back is a stock change of the article and both kinds too. const events = await stockChangedOf(batchId); expect(events).toHaveLength(2); for (const event of events) { expect(event.payload).toEqual({ bottlingBatchIds: [], productIds: [productId], rawMaterialKinds: expect.arrayContaining([ 'BEESWAX', 'PROPOLIS', ]) as unknown, }); } }); it('refuses to correct a raw lot below what was already used or withdrawn', async () => { // wax: 1000 g harvested, 100 g withdrawn -> at least 100 g must remain booked await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`)) .send({ quantity: 50 }) .expect(400); await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`)) .send({ quantity: 950 }) .expect(200); expect((await stockOf(waxId)).summary.remaining).toBe(850); }); it('needs the raw material withdraw permission to consume raw material', async () => { const role = await prisma.role.create({ data: { key: `PRODUCER_ONLY_${suffix}`, name: `Nur Herstellung ${suffix}`, permissions: { create: ( await prisma.permission.findMany({ where: { key: { in: ['production-batch:read', 'production-batch:create'], }, }, }) ).map((permission) => ({ permissionId: permission.id })), }, }, }); await prisma.user.create({ data: { email: `raw-producer-${suffix}@example.com`, passwordHash: await bcrypt.hash(password, 12), name: 'Producer', roleId: role.id, }, }); const producerToken = ( await login(app, `raw-producer-${suffix}@example.com`, password) ).accessToken; await request(server()) .post('/api/v1/production-batches') .set('Authorization', `Bearer ${producerToken}`) .send({ productId, lotNumber: `S3-${suffix}`, date: '2026-06-12', quantity: 5, inputs: [{ rawMaterialLotId: propolisId, quantity: 10 }], }) .expect(403); 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); }); it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => { // The rule is shared by every e2e file: switch it on with a minimum no // test database reaches, and put it back right after. const before = await prisma.automationRule.findUnique({ where: { triggerType: 'RAW_MATERIAL_LOW' }, }); const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } }; // One open task per kind: a leftover from an aborted earlier run would // keep the handler from creating a new one - close it first. await prisma.task.updateMany({ where: { sourceAutomationTrigger: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', status: { in: ['OPEN', 'IN_PROGRESS'] }, }, data: { status: 'DONE', completedAt: new Date() }, }); await prisma.automationRule.upsert({ where: { triggerType: 'RAW_MATERIAL_LOW' }, create: { triggerType: 'RAW_MATERIAL_LOW', active: true, config, actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.', }, update: { active: true, config }, }); try { const response = await auth( request(server()).post( `/api/v1/raw-material-lots/${propolisId}/withdrawals`, ), ) .send({ quantity: 10, reason: 'PROBE' }) .expect(201); // Newest first: the withdrawal just booked. const [movement] = (response.body as StockBody).movements; const [event] = await prisma.domainEvent.findMany({ where: { type: 'StockChanged', aggregateId: movement.id }, select: { correlationId: true }, }); const ruleTasksOfThisChange = async () => ( await prisma.domainEvent.findMany({ where: { type: 'TaskCreated', correlationId: event.correlationId, }, select: { aggregateId: true }, }) ).map((created) => created.aggregateId); // The request itself creates no task - the handler does. expect(await ruleTasksOfThisChange()).toEqual([]); expect( await prisma.task.count({ where: { sourceAutomationTrigger: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', createdAt: { gte: startedAt }, }, }), ).toBe(0); await drainDomainEvents(app, [movement.id]); const taskIds = await ruleTasksOfThisChange(); ruleTaskIds.push(...taskIds); expect(taskIds).toHaveLength(1); expect( await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }), ).toMatchObject({ sourceAutomationTrigger: 'RAW_MATERIAL_LOW', relatedType: 'GENERAL', relatedId: 'PROPOLIS', status: 'OPEN', }); } finally { // Leave the rule as found: restored, or gone again if this test // created it (ensureDefaults then recreates it with its defaults). if (before === null) { await prisma.automationRule.delete({ where: { triggerType: 'RAW_MATERIAL_LOW' }, }); } else { await prisma.automationRule.update({ where: { triggerType: 'RAW_MATERIAL_LOW' }, data: { active: before.active, config: restoredConfig(before) }, }); } } }); });