manicanldes-backend/test/inventory.spec.ts

256 lines
7.7 KiB
TypeScript

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]);
},
);
});