manicanldes-backend/test/checkout.spec.ts

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