import { randomUUID } from 'node:crypto'; import { identityApp, type IdentityApp } from './helpers/identity-app'; import { seedStock, secondActor } from './helpers/commerce'; describe('stock ledger and reservations', () => { let ctx: IdentityApp; beforeAll(async () => { ctx = await identityApp(); }, 60000); afterAll(async () => { await ctx?.close(); }); beforeEach(async () => { await ctx.clearLimits(); }); const auth = { type: 'bearer' as const }; const reserve = ( stockItemId: string, quantity: number, idempotencyKey = randomUUID(), ) => ctx .api() .post('/api/v1/inventory/reservations') .auth(ctx.token, auth) .send({ stockItemId, quantity, idempotencyKey }); it('replays identical adjustments once and rejects changed payloads', async () => { const { stock } = await seedStock(ctx); const input = { stockItemId: stock.id, delta: 5, reason: 'Count correction', idempotencyKey: randomUUID(), }; const first = await ctx .api() .post('/api/v1/inventory/adjustments') .auth(ctx.token, auth) .send(input) .expect(201); const again = await ctx .api() .post('/api/v1/inventory/adjustments') .auth(ctx.token, auth) .send(input) .expect(201); expect(again.body.id).toBe(first.body.id); const conflict = await ctx .api() .post('/api/v1/inventory/adjustments') .auth(ctx.token, auth) .send({ ...input, delta: 6 }) .expect(409); expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT'); const balance = await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}`) .auth(ctx.token, auth) .expect(200); expect(balance.body).toMatchObject({ onHand: 15, reserved: 0, available: 15, }); }); it('prevents overselling and adjustments below active reservations', async () => { const { stock } = await seedStock(ctx); const key = randomUUID(); const first = await reserve(stock.id, 7, key).expect(201); expect((await reserve(stock.id, 7, key).expect(201)).body.id).toBe( first.body.id, ); await reserve(stock.id, 8, key).expect(409); const denied = await reserve(stock.id, 4).expect(409); expect(denied.body.code).toBe('STOCK_INSUFFICIENT'); await ctx .api() .post('/api/v1/inventory/adjustments') .auth(ctx.token, auth) .send({ stockItemId: stock.id, delta: -4, reason: 'Bad correction', idempotencyKey: randomUUID(), }) .expect(409); const balance = await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}`) .auth(ctx.token, auth) .expect(200); expect(balance.body).toMatchObject({ onHand: 10, reserved: 7, available: 3, }); }); it('releases a hold idempotently and prevents committing a released hold', async () => { const { stock } = await seedStock(ctx); const hold = await reserve(stock.id, 4).expect(201); const route = `/api/v1/inventory/reservations/${hold.body.id}`; await ctx.api().get(route).auth(ctx.token, auth).expect(200); await ctx .api() .post(route + '/release') .auth(ctx.token, auth) .expect(200); await ctx .api() .post(route + '/release') .auth(ctx.token, auth) .expect(200); const denied = await ctx .api() .post(route + '/commit') .auth(ctx.token, auth) .expect(409); expect(denied.body.code).toBe('RESERVATION_CLOSED'); expect( ( await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}`) .auth(ctx.token, auth) ).body.available, ).toBe(10); }); it('fulfils once and reconciles on-hand balance with the ledger', async () => { const { stock } = await seedStock(ctx); const hold = await reserve(stock.id, 4).expect(201); const route = `/api/v1/inventory/reservations/${hold.body.id}/commit`; await ctx.api().post(route).auth(ctx.token, auth).expect(200); await ctx.api().post(route).auth(ctx.token, auth).expect(200); const ledger = await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}/ledger`) .auth(ctx.token, auth) .expect(200); expect( ledger.body.reduce( (sum: number, row: { delta: number }) => sum + row.delta, 0, ), ).toBe(6); expect(ledger.body).toHaveLength(2); expect( ( await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}`) .auth(ctx.token, auth) ).body.onHand, ).toBe(6); await expect( ctx.executeSql( `UPDATE stock_ledger SET delta = 99 WHERE id = '${ledger.body[0].id}'`, ), ).rejects.toThrow('append-only'); }); it('ignores expired holds without a cleanup job and rejects late commit', async () => { const { stock } = await seedStock(ctx); const hold = await reserve(stock.id, 10).expect(201); await ctx.db.stockReservation.update({ where: { id: hold.body.id }, data: { expiresAt: new Date(0) }, }); expect( ( await ctx .api() .get(`/api/v1/inventory/reservations/${hold.body.id}`) .auth(ctx.token, auth) ).body.status, ).toBe('EXPIRED'); const response = await ctx .api() .post(`/api/v1/inventory/reservations/${hold.body.id}/commit`) .auth(ctx.token, auth) .expect(409); expect(response.body.code).toBe('RESERVATION_EXPIRED'); await reserve(stock.id, 10).expect(201); }); it('enforces scope, reservation ownership and product sellability', async () => { const { stock, product } = await seedStock(ctx); const outsider = await secondActor(ctx, false, [ 'inventory.read', 'inventory.reserve', ]); await ctx .api() .get(`/api/v1/inventory/stock-items/${stock.id}`) .auth(outsider.token, auth) .expect(404); const hold = await reserve(stock.id, 1).expect(201); const colleague = await secondActor(ctx, true, ['inventory.reserve']); await ctx .api() .get(`/api/v1/inventory/reservations/${hold.body.id}`) .auth(colleague.token, auth) .expect(404); await ctx .api() .post(`/api/v1/inventory/reservations/${hold.body.id}/release`) .auth(colleague.token, auth) .expect(404); await ctx .api() .patch(`/api/v1/products/${product.id}/status`) .auth(ctx.token, auth) .send({ status: 'DRAFT' }) .expect(200); expect((await reserve(stock.id, 1).expect(409)).body.code).toBe( 'PRODUCT_UNAVAILABLE', ); }); it('lists scoped warehouses and stock and rejects malformed quantities', async () => { const { stock } = await seedStock(ctx); await ctx .api() .get('/api/v1/inventory/warehouses?limit=2') .auth(ctx.token, auth) .expect(200); await ctx .api() .get('/api/v1/inventory/stock-items?limit=2') .auth(ctx.token, auth) .expect(200); await reserve(stock.id, 0).expect(400); await reserve(stock.id, 1.5).expect(400); await ctx .api() .post('/api/v1/inventory/adjustments') .auth(ctx.token, auth) .send({ stockItemId: stock.id, delta: 0, reason: 'Invalid', idempotencyKey: randomUUID(), }) .expect(400); }); const nativeOnly = process.env.TEST_DATABASE_URL ? it : it.skip; nativeOnly( 'serializes competing reservations on native PostgreSQL', async () => { const { stock } = await seedStock(ctx, 1); const results = await Promise.all([ reserve(stock.id, 1), reserve(stock.id, 1), ]); expect(results.map((row) => row.status).sort()).toEqual([201, 409]); }, ); });