manicanldes-backend/test/orders.spec.ts

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