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; // Suggestions the RAW_MATERIAL_LOW handler created for this file. const ruleSuggestionIds: 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 suggestions are written without a user: their audit entries and // warnings go by suggestion id. await prisma.auditLog.deleteMany({ where: { OR: [ { entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } }, ...ruleSuggestionIds.map((suggestionId) => ({ entityType: 'AutomationRule', afterData: { path: ['suggestionId'], equals: suggestionId }, })), ], }, }); await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: ruleSuggestionIds }, }, }); await prisma.suggestion.deleteMany({ where: { id: { in: ruleSuggestionIds } }, }); const aggregateIds = [...batchIds, ...movementIds]; await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, }); await prisma.auditLog.deleteMany({ where: { entityId: { in: [...lotIds, ...aggregateIds] } }, }); 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('proposes the RAW_MATERIAL_LOW reorder 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 } }; // One open suggestion per kind: a leftover from an aborted earlier run // would keep the handler from proposing a new one - settle it first. await prisma.suggestion.updateMany({ where: { ruleKey: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', status: 'OPEN', }, data: { status: 'DISMISSED' }, }); 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 suggestionsOfThisChange = () => prisma.suggestion.findMany({ where: { ruleKey: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', createdAt: { gte: startedAt }, }, }); // The request itself proposes nothing - the handler does. expect(await suggestionsOfThisChange()).toEqual([]); await drainDomainEvents(app, [movement.id]); const suggestions = await suggestionsOfThisChange(); ruleSuggestionIds.push(...suggestions.map((found) => found.id)); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ ruleKey: 'RAW_MATERIAL_LOW', intent: 'STOCK_REORDER', reasonCode: 'RAW_MATERIAL_LOW', relatedType: 'GENERAL', relatedId: 'PROPOLIS', colonyId: null, requiredPermission: 'raw-material:read', status: 'OPEN', dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`, }); // The request creates no task either. expect( await prisma.task.count({ where: { sourceAutomationTrigger: 'RAW_MATERIAL_LOW', relatedId: 'PROPOLIS', createdAt: { gte: startedAt }, }, }), ).toBe(0); } 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) }, }); } } }); });