256 lines
7.7 KiB
TypeScript
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]);
|
|
},
|
|
);
|
|
});
|