119 lines
3.6 KiB
TypeScript
119 lines
3.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
|
import { checkoutFixture, couponInput } from './helpers/checkout';
|
|
import { secondActor, addressInput } from './helpers/commerce';
|
|
|
|
const native = process.env.TEST_DATABASE_URL ? describe : describe.skip;
|
|
native('native PostgreSQL checkout concurrency', () => {
|
|
let ctx: IdentityApp;
|
|
beforeAll(async () => {
|
|
ctx = await identityApp();
|
|
}, 60000);
|
|
afterAll(async () => {
|
|
await ctx.close();
|
|
});
|
|
beforeEach(async () => {
|
|
await ctx.clearLimits();
|
|
});
|
|
it('creates one order for two simultaneous identical requests', async () => {
|
|
const f = await checkoutFixture(ctx);
|
|
const responses = await Promise.all(
|
|
[1, 2].map(() =>
|
|
ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input),
|
|
),
|
|
);
|
|
expect(responses.map((response) => response.status)).toEqual([201, 201]);
|
|
expect(responses[0]!.body.id).toBe(responses[1]!.body.id);
|
|
expect(
|
|
await ctx.db.stockReservation.count({
|
|
where: { userId: f.actor.userId },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
it('lets only one competing checkout claim the final stock and coupon use', async () => {
|
|
const f = await checkoutFixture(ctx, 2);
|
|
const other = await secondActor(ctx);
|
|
const address = await ctx
|
|
.api()
|
|
.post('/api/v1/addresses')
|
|
.auth(other.token, { type: 'bearer' })
|
|
.send(addressInput)
|
|
.expect(201);
|
|
await ctx
|
|
.api()
|
|
.put('/api/v1/cart/lines/' + f.variant.id)
|
|
.auth(other.token, { type: 'bearer' })
|
|
.send({ quantity: 2, version: 0 })
|
|
.expect(200);
|
|
const coupon = await ctx
|
|
.api()
|
|
.post('/api/v1/coupons')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.send({ ...couponInput(), maxUses: 1 })
|
|
.expect(201);
|
|
const commands = [
|
|
{ actor: f.actor, input: { ...f.input, couponCode: coupon.body.code } },
|
|
{
|
|
actor: other,
|
|
input: {
|
|
...f.input,
|
|
addressId: address.body.id,
|
|
idempotencyKey: randomUUID(),
|
|
couponCode: coupon.body.code,
|
|
},
|
|
},
|
|
];
|
|
const responses = await Promise.all(
|
|
commands.map(({ actor, input }) =>
|
|
ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(actor.token, { type: 'bearer' })
|
|
.send(input),
|
|
),
|
|
);
|
|
expect(responses.map((response) => response.status).sort()).toEqual([
|
|
201, 409,
|
|
]);
|
|
expect(
|
|
await ctx.db.order.count({ where: { couponId: coupon.body.id } }),
|
|
).toBe(1);
|
|
const reserved = await ctx.db.stockReservation.aggregate({
|
|
where: { stockItemId: f.stock.id },
|
|
_sum: { quantity: true },
|
|
});
|
|
expect(reserved._sum.quantity).toBe(2);
|
|
});
|
|
it('coordinates checkout with a standalone inventory reservation', async () => {
|
|
const f = await checkoutFixture(ctx, 2);
|
|
const responses = await Promise.all([
|
|
ctx
|
|
.api()
|
|
.post('/api/v1/checkout')
|
|
.auth(f.actor.token, { type: 'bearer' })
|
|
.send(f.input),
|
|
ctx
|
|
.api()
|
|
.post('/api/v1/inventory/reservations')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.send({
|
|
stockItemId: f.stock.id,
|
|
quantity: 2,
|
|
idempotencyKey: randomUUID(),
|
|
}),
|
|
]);
|
|
expect(responses.map((response) => response.status).sort()).toEqual([
|
|
201, 409,
|
|
]);
|
|
const held = await ctx.db.stockReservation.aggregate({
|
|
where: { stockItemId: f.stock.id },
|
|
_sum: { quantity: true },
|
|
});
|
|
expect(held._sum.quantity).toBe(2);
|
|
});
|
|
});
|