181 lines
5.6 KiB
TypeScript
181 lines
5.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
|
import { checkoutFixture, couponInput } from './helpers/checkout';
|
|
import { secondActor, seedStock, addressInput } from './helpers/commerce';
|
|
|
|
describe('atomic checkout and order snapshots', () => {
|
|
let ctx: IdentityApp;
|
|
beforeAll(async () => {
|
|
ctx = await identityApp();
|
|
});
|
|
afterAll(async () => {
|
|
await ctx.close();
|
|
});
|
|
beforeEach(async () => {
|
|
await ctx.clearLimits();
|
|
});
|
|
it('snapshots server prices and address, holds stock, clears cart and replays safely', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const created = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
expect(created.body).toMatchObject({
|
|
subtotal: '998',
|
|
discount: '0',
|
|
merchandiseTotal: '998',
|
|
status: 'PENDING_PAYMENT',
|
|
pricingStatus: 'UNFINALIZED',
|
|
payableTotal: null,
|
|
paymentAvailable: false,
|
|
});
|
|
expect(created.body.requestHash).toBeUndefined();
|
|
expect(
|
|
await ctx.db.stockReservation.count({
|
|
where: { orderId: created.body.id },
|
|
}),
|
|
).toBe(1);
|
|
expect(
|
|
(await ctx.db.stockItem.findUniqueOrThrow({ where: { id: f.stock.id } }))
|
|
.onHand,
|
|
).toBe(10);
|
|
await ctx.db.productVariant.update({
|
|
where: { id: f.variant.id },
|
|
data: { price: '1.00', name: 'Changed' },
|
|
});
|
|
await ctx
|
|
.api()
|
|
.put('/api/v1/addresses/' + f.address.id)
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ ...addressInput, recipient: 'Changed' })
|
|
.expect(200);
|
|
const replay = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(201);
|
|
expect(replay.body).toEqual(created.body);
|
|
expect(replay.body.address.recipient).toBe(addressInput.recipient);
|
|
const cart = await ctx
|
|
.api()
|
|
.get('/api/v1/cart')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.expect(200);
|
|
expect(cart.body).toEqual({ version: 2, lines: [] });
|
|
const conflict = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ ...f.input, cartVersion: 2 })
|
|
.expect(409);
|
|
expect(conflict.body.code).toBe('IDEMPOTENCY_CONFLICT');
|
|
});
|
|
it('rolls back orders and partial holds on a stock shortage, preserving cart', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const unavailable = await seedStock(ctx, 0);
|
|
await ctx
|
|
.api()
|
|
.put('/api/v1/cart/lines/' + unavailable.variant.id)
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ quantity: 1, version: 1 })
|
|
.expect(200);
|
|
const response = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ ...f.input, cartVersion: 2 })
|
|
.expect(409);
|
|
expect(response.body.code).toBe('STOCK_INSUFFICIENT');
|
|
expect(
|
|
await ctx.db.order.count({ where: { userId: f.actor.userId } }),
|
|
).toBe(0);
|
|
expect(
|
|
await ctx.db.stockReservation.count({
|
|
where: { userId: f.actor.userId },
|
|
}),
|
|
).toBe(0);
|
|
expect(
|
|
await ctx.db.cartLine.count({
|
|
where: { cart: { userId: f.actor.userId } },
|
|
}),
|
|
).toBe(2);
|
|
});
|
|
it('validates live cart version, private address and coupon eligibility before writes', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
for (const [change, code] of [
|
|
[{ cartVersion: 0 }, 'CART_CHANGED'],
|
|
[{ addressId: randomUUID() }, 'ADDRESS_NOT_FOUND'],
|
|
[{ couponCode: 'MISSING' }, 'COUPON_INELIGIBLE'],
|
|
] as const) {
|
|
const response = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ ...f.input, ...change })
|
|
.expect(code === 'ADDRESS_NOT_FOUND' ? 404 : 409);
|
|
expect(response.body.code).toBe(code);
|
|
}
|
|
const other = await secondActor(ctx);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(other.token, { type: 'bearer' })
|
|
.send(f.input)
|
|
.expect(409);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ ...f.input, subtotal: '0.01' })
|
|
.expect(400);
|
|
});
|
|
it('applies a coupon exactly once and releases its usage on cancellation', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const coupon = await ctx
|
|
.api()
|
|
.post('/api/v1/coupons')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.send(couponInput())
|
|
.expect(201);
|
|
const input = { ...f.input, couponCode: coupon.body.code };
|
|
const order = await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(input)
|
|
.expect(201);
|
|
expect(order.body).toMatchObject({
|
|
subtotal: '998',
|
|
discount: '99.8',
|
|
merchandiseTotal: '898.2',
|
|
});
|
|
await ctx
|
|
.api()
|
|
.put('/api/v1/cart/lines/' + f.variant.id)
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send({ quantity: 1, version: 2 })
|
|
.expect(200);
|
|
const next = { ...input, cartVersion: 3, idempotencyKey: randomUUID() };
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(next)
|
|
.expect(409);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/orders/' + order.body.id + '/cancel')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.expect(201);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(next)
|
|
.expect(201);
|
|
});
|
|
});
|