192 lines
6.0 KiB
TypeScript
192 lines
6.0 KiB
TypeScript
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
|
import { checkoutFixture } from './helpers/checkout';
|
|
import { secondActor } from './helpers/commerce';
|
|
|
|
describe('order ownership and cancellation', () => {
|
|
let ctx: IdentityApp;
|
|
beforeAll(async () => {
|
|
ctx = await identityApp();
|
|
}, 60000);
|
|
afterAll(async () => {
|
|
await ctx.close();
|
|
});
|
|
beforeEach(async () => {
|
|
await ctx.clearLimits();
|
|
});
|
|
it('isolates customers and organizations while allowing scoped staff reads', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const order = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
const other = await secondActor(ctx);
|
|
const foreign = await secondActor(ctx, false, [
|
|
'orders.read',
|
|
'orders.manage',
|
|
]);
|
|
for (const actor of [other, foreign]) {
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/orders/' + order.body.id)
|
|
.auth(actor.token, { type: 'bearer' })
|
|
.expect(404);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
|
.auth(actor.token, { type: 'bearer' })
|
|
.expect(404);
|
|
}
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/admin/orders/' + order.body.id)
|
|
.auth(other.token, { type: 'bearer' })
|
|
.expect(403);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/admin/orders/' + order.body.id)
|
|
.auth(foreign.token, { type: 'bearer' })
|
|
.expect(404);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
|
|
.auth(foreign.token, { type: 'bearer' })
|
|
.expect(404);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/admin/orders/' + order.body.id)
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(200);
|
|
const own = await ctx
|
|
.api()
|
|
.get('/api/v1/orders')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.expect(200);
|
|
expect(own.body.map((row: { id: string }) => row.id)).toEqual([
|
|
order.body.id,
|
|
]);
|
|
const others = await ctx
|
|
.api()
|
|
.get('/api/v1/orders')
|
|
.auth(other.token, { type: 'bearer' })
|
|
.expect(200);
|
|
expect(others.body).toEqual([]);
|
|
const staff = await ctx
|
|
.api()
|
|
.get('/api/v1/admin/orders?limit=1')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(200);
|
|
expect(staff.body).toHaveLength(1);
|
|
expect(staff.body[0].address).toBeUndefined();
|
|
});
|
|
it('cancels once, releases holds and blocks standalone reservation transitions', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const order = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
const hold = await ctx.db.stockReservation.findFirstOrThrow({
|
|
where: { orderId: order.body.id },
|
|
});
|
|
const denied = await ctx
|
|
.api()
|
|
.post('/api/v1/inventory/reservations/' + hold.id + '/commit')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(409);
|
|
expect(denied.body.code).toBe('ORDER_RESERVATION_MANAGED');
|
|
const result = await ctx
|
|
.api()
|
|
.post('/api/v1/admin/orders/' + order.body.id + '/cancel')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(201);
|
|
expect(result.body.status).toBe('CANCELLED');
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.expect(201);
|
|
expect(
|
|
(
|
|
await ctx.db.stockReservation.findUniqueOrThrow({
|
|
where: { id: hold.id },
|
|
})
|
|
).status,
|
|
).toBe('RELEASED');
|
|
expect(
|
|
await ctx.db.auditEvent.count({
|
|
where: { targetId: order.body.id, action: 'order.cancelled' },
|
|
}),
|
|
).toBe(1);
|
|
expect(
|
|
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
|
|
.onHand,
|
|
).toBe(10);
|
|
});
|
|
it('enforces immutable snapshots and line arithmetic through SQL', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const order = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
await expect(
|
|
ctx.executeSql(
|
|
`UPDATE orders SET subtotal = 1 WHERE id = '${order.body.id}'`,
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
ctx.executeSql(`DELETE FROM orders WHERE id = '${order.body.id}'`),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
ctx.executeSql(
|
|
`UPDATE order_lines SET quantity = 10 WHERE order_id = '${order.body.id}'`,
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
ctx.executeSql(
|
|
`DELETE FROM order_lines WHERE order_id = '${order.body.id}'`,
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
ctx.executeSql(
|
|
`UPDATE stock_reservations SET order_id = NULL WHERE order_id = '${order.body.id}'`,
|
|
),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
ctx.executeSql(
|
|
`DELETE FROM stock_reservations WHERE order_id = '${order.body.id}'`,
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
it('rejects a later line insertion that would change a committed order snapshot', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const result = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
const variant = await ctx.db.productVariant.create({
|
|
data: {
|
|
organizationId: f.actor.organizationId,
|
|
productId: f.product.id,
|
|
sku: result.body.id,
|
|
name: 'Extra line',
|
|
price: '1.00',
|
|
},
|
|
});
|
|
await expect(
|
|
ctx.executeSql(`INSERT INTO order_lines
|
|
(id, order_id, organization_id, variant_id, sku, product_name, variant_name, quantity, unit_price, line_total)
|
|
VALUES (gen_random_uuid(), '${result.body.id}', '${f.actor.organizationId}', '${variant.id}', 'EXTRA', 'Extra', 'Extra', 1, 1, 1)`),
|
|
).rejects.toThrow('Order subtotal does not match lines');
|
|
expect(
|
|
await ctx.db.orderLine.count({ where: { orderId: result.body.id } }),
|
|
).toBe(1);
|
|
});
|
|
});
|