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; soldInShop: number; withdrawn: number; corrected: number; remaining: number; }; movements: { id: string; type: string; quantity: number }[]; } interface ShopResult { externalId: string; status: 'created' | 'duplicate' | 'rejected'; error?: string; allocations: { batchId: string; lotNumber: string; quantity: number }[]; remainingCount: number | null; } /** * Stock ledger end to end: withdrawals and inventory corrections in ApiCore, * the shop's sales and returns via API key, oldest-batch-first allocation, * idempotency and the row lock against concurrent bookings. */ describe('Stock ledger (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let beekeeperId: string; let beekeeperToken: string; let adminToken: string; let writeKey: string; let readKey: string; let bottlingId: string; let candleId: string; let oldBatchId: string; let newBatchId: string; const server = () => app.getHttpServer(); const asBeekeeper = (req: request.Test) => req.set('Authorization', `Bearer ${beekeeperToken}`); /** The StockChanged events recorded for one movement, shop request or batch. */ function stockChangedOf(aggregateId: string) { return prisma.domainEvent.findMany({ where: { type: 'StockChanged', aggregateId }, select: { aggregateType: true, payload: true, actorUserId: true }, }); } async function shopRequestId(externalId: string): Promise { return ( await prisma.shopStockRequest.findUniqueOrThrow({ where: { externalId }, select: { id: true }, }) ).id; } function book(token: string, requests: Record[]) { return request(server()) .post('/api/v1/shop-sync/movements') .set('Authorization', `Bearer ${token}`) .send({ requests }); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: `stock-beekeeper-${suffix}@example.com`, passwordHash, name: 'Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; await prisma.user.create({ data: { email: `stock-admin-${suffix}@example.com`, passwordHash, name: 'Admin', role: { connect: { key: 'ADMIN' } }, }, }); beekeeperToken = ( await login(app, `stock-beekeeper-${suffix}@example.com`, password) ).accessToken; adminToken = ( await login(app, `stock-admin-${suffix}@example.com`, password) ).accessToken; const createKey = async (scopes: string[]) => { const response = await request(server()) .post('/api/v1/api-keys') .set('Authorization', `Bearer ${adminToken}`) .send({ name: `Stock ${scopes.join('+')} ${suffix}`, scopes }) .expect(201); return (response.body as { token: string }).token; }; writeKey = await createKey(['shop-sync:write']); readKey = await createKey(['shop-sync:read']); // Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling. const apiary = await prisma.apiary.create({ data: { name: `Stand ${suffix}` }, }); const harvest = await prisma.harvest.create({ data: { apiaryId: apiary.id, date: new Date(), userId: beekeeper.id }, }); const extraction = await prisma.extraction.create({ data: { date: new Date(), userId: beekeeper.id }, }); await prisma.extractionHarvest.create({ data: { extractionId: extraction.id, harvestId: harvest.id }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `HC-${suffix}`, extractionId: extraction.id, amount: 50, }, }); const bottling = await asBeekeeper( request(server()).post('/api/v1/bottling-batches'), ) .send({ honeyBatchId: honeyBatch.id, date: '2026-07-01', jarSize: 500, jarCount: 10, bestBeforeDate: '2028-07-01', lotNumber: `L-${suffix}`, }) .expect(201); bottlingId = (bottling.body as { id: string }).id; const candle = await prisma.product.create({ data: { name: `Kerze ${suffix}`, kind: 'MANUFACTURED', shopCategory: 'KERZEN', }, }); candleId = candle.id; const createBatch = async (lot: string, quantity: number, bbd: string) => { const response = await asBeekeeper( request(server()).post('/api/v1/production-batches'), ) .send({ productId: candleId, lotNumber: `${lot}-${suffix}`, date: '2026-03-01', quantity, bestBeforeDate: bbd, }) .expect(201); return (response.body as { id: string }).id; }; newBatchId = await createBatch('K-NEW', 5, '2027-06-01'); oldBatchId = await createBatch('K-OLD', 3, '2027-01-01'); }); afterAll(async () => { // Events and audit entries of this file only, by its own ids - other // e2e files share the database. // Only ids that exist: an undefined id would drop the filter. const batchIds = [bottlingId, newBatchId, oldBatchId].filter(Boolean); const movementIds = ( await prisma.batchMovement.findMany({ where: { OR: [ { bottlingBatchId: { in: batchIds } }, { productionBatchId: { in: batchIds } }, ], }, select: { id: true }, }) ).map((movement) => movement.id); const requestIds = ( await prisma.shopStockRequest.findMany({ where: { externalId: { endsWith: suffix } }, select: { id: true }, }) ).map((shopRequest) => shopRequest.id); const aggregateIds = [...batchIds, ...movementIds, ...requestIds]; await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: aggregateIds } }, }); await prisma.auditLog.deleteMany({ where: { entityId: { in: aggregateIds } }, }); await app.close(); }); it('withdraws from a bottling and refuses more than the remaining stock', async () => { const response = await asBeekeeper( request(server()).post( `/api/v1/bottling-batches/${bottlingId}/withdrawals`, ), ) .send({ quantity: 2, reason: 'HOFLADEN' }) .expect(201); expect((response.body as StockBody).summary).toMatchObject({ initial: 10, withdrawn: 2, remaining: 8, }); await asBeekeeper( request(server()).post( `/api/v1/bottling-batches/${bottlingId}/withdrawals`, ), ) .send({ quantity: 20, reason: 'BRUCH' }) .expect(400); }); it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => { // The withdrawal of 2 above - the refused one of 20 left no trace. const movements = await prisma.batchMovement.findMany({ where: { bottlingBatchId: bottlingId }, select: { id: true, type: true, quantity: true }, }); expect(movements).toEqual([ expect.objectContaining({ type: 'WITHDRAWAL', quantity: -2 }), ]); const [movement] = movements; expect( await prisma.auditLog.count({ where: { entityType: 'BatchMovement', entityId: movement.id }, }), ).toBe(1); expect(await stockChangedOf(movement.id)).toEqual([ { aggregateType: 'BatchMovement', payload: { bottlingBatchIds: [bottlingId], productIds: [], rawMaterialKinds: [], }, actorUserId: beekeeperId, }, ]); }); it('a production batch records StockChanged for its article', async () => { expect(await stockChangedOf(newBatchId)).toEqual([ { aggregateType: 'ProductionBatch', payload: { bottlingBatchIds: [], productIds: [candleId], rawMaterialKinds: [], }, actorUserId: beekeeperId, }, ]); }); it('books a shop sale once, even when it is sent twice', async () => { const sale = { externalId: `sale-honey-${suffix}`, kind: 'SALE', bottlingBatchId: bottlingId, quantity: 3, }; const first = await book(writeKey, [sale]).expect(200); const [created] = (first.body as { results: ShopResult[] }).results; expect(created.status).toBe('created'); expect(created.remainingCount).toBe(5); const second = await book(writeKey, [sale]).expect(200); const [duplicate] = (second.body as { results: ShopResult[] }).results; expect(duplicate.status).toBe('duplicate'); expect(duplicate.remainingCount).toBe(5); // One event for the booking - the repeat booked nothing. expect(await stockChangedOf(await shopRequestId(sale.externalId))).toEqual([ { aggregateType: 'ShopStockRequest', payload: { bottlingBatchIds: [bottlingId], productIds: [], rawMaterialKinds: [], }, actorUserId: null, }, ]); }); it('allocates an article sale to the batch with the earliest best-before date first', async () => { const response = await book(writeKey, [ { externalId: `sale-candle-${suffix}`, kind: 'SALE', productId: candleId, quantity: 4, }, ]).expect(200); const [result] = (response.body as { results: ShopResult[] }).results; expect(result.status).toBe('created'); expect( result.allocations.map((a) => [a.batchId, a.quantity]).sort(), ).toEqual( [ [oldBatchId, 3], [newBatchId, 1], ].sort(), ); expect(result.remainingCount).toBe(4); }); it('returns onto exactly the batches of the sale and refuses to return more', async () => { const response = await book(writeKey, [ { externalId: `return-candle-${suffix}`, kind: 'RETURN', productId: candleId, quantity: 2, reversesExternalId: `sale-candle-${suffix}`, }, { externalId: `return-too-much-${suffix}`, kind: 'RETURN', productId: candleId, quantity: 5, reversesExternalId: `sale-candle-${suffix}`, }, ]).expect(200); const [returned, tooMuch] = (response.body as { results: ShopResult[] }) .results; expect(returned.status).toBe('created'); const perBatch = new Map( returned.allocations.map((a) => [a.batchId, a.quantity]), ); expect(perBatch.get(newBatchId)).toBe(1); expect(perBatch.get(oldBatchId)).toBe(1); expect(returned.remainingCount).toBe(6); expect(tooMuch.status).toBe('rejected'); }); it('a shop request records one StockChanged per accepted booking', async () => { const sale = await stockChangedOf( await shopRequestId(`sale-candle-${suffix}`), ); const returned = await stockChangedOf( await shopRequestId(`return-candle-${suffix}`), ); for (const events of [sale, returned]) { expect(events).toEqual([ { aggregateType: 'ShopStockRequest', payload: { bottlingBatchIds: [], productIds: [candleId], rawMaterialKinds: [], }, actorUserId: null, }, ]); } // The rejected return was rolled back together with its event. expect( await prisma.shopStockRequest.count({ where: { externalId: `return-too-much-${suffix}` }, }), ).toBe(0); }); it('rejects a bad request but still books the rest of the batch', async () => { const response = await book(writeKey, [ { externalId: `bad-${suffix}`, kind: 'SALE', productId: 'does-not-exist', quantity: 1, }, { externalId: `good-${suffix}`, kind: 'SALE', bottlingBatchId: bottlingId, quantity: 1, }, ]).expect(200); const [bad, good] = (response.body as { results: ShopResult[] }).results; expect(bad.status).toBe('rejected'); expect(good.status).toBe('created'); expect(good.remainingCount).toBe(4); }); it('needs shop-sync:write to book', async () => { await book(readKey, [ { externalId: `nope-${suffix}`, kind: 'SALE', bottlingBatchId: bottlingId, quantity: 1, }, ]).expect(403); }); it('serves the remaining stock to the shop', async () => { const response = await request(server()) .get('/api/v1/shop-sync/stock') .set('Authorization', `Bearer ${readKey}`) .expect(200); const body = response.body as { bottlings: { id: string; remainingCount: number }[]; products: { id: string; remainingCount: number }[]; }; expect( body.bottlings.find((b) => b.id === bottlingId)?.remainingCount, ).toBe(4); expect(body.products.find((p) => p.id === candleId)?.remainingCount).toBe( 6, ); }); it('serializes concurrent withdrawals so the stock never goes below zero', async () => { // newBatch: 5 produced - 1 sold + 1 returned = 5 left; 8 parallel takes of 1. const attempts = await Promise.all( Array.from({ length: 8 }, () => asBeekeeper( request(server()).post( `/api/v1/production-batches/${newBatchId}/withdrawals`, ), ).send({ quantity: 1, reason: 'PROBE' }), ), ); expect(attempts.filter((r) => r.status === 201)).toHaveLength(5); expect(attempts.filter((r) => r.status === 400)).toHaveLength(3); // One StockChanged per booked withdrawal, none for a refused one. const withdrawals = await prisma.batchMovement.findMany({ where: { productionBatchId: newBatchId, type: 'WITHDRAWAL' }, select: { id: true }, }); expect(withdrawals).toHaveLength(5); expect( await prisma.domainEvent.count({ where: { type: 'StockChanged', aggregateId: { in: withdrawals.map((movement) => movement.id) }, }, }), ).toBe(5); const stock = await asBeekeeper( request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`), ).expect(200); expect((stock.body as StockBody).summary.remaining).toBe(0); }); it('books an inventory count as the difference', async () => { const response = await asBeekeeper( request(server()).post( `/api/v1/bottling-batches/${bottlingId}/corrections`, ), ) .send({ countedQuantity: 6, notes: 'Zwei Gläser im Keller gefunden' }) .expect(201); const body = response.body as StockBody; expect(body.summary.corrected).toBe(2); expect(body.summary.remaining).toBe(6); }); it('refuses to delete a bottling with movements', async () => { await request(server()) .delete(`/api/v1/bottling-batches/${bottlingId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(409); }); it('refuses a production batch for a honey article', async () => { const honey = await prisma.product.create({ data: { name: `Honig ${suffix}` }, }); await asBeekeeper(request(server()).post('/api/v1/production-batches')) .send({ productId: honey.id, lotNumber: `X-${suffix}`, date: '2026-03-01', quantity: 1, }) .expect(400); }); });