cabloy 5.1.115 → 5.1.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cabloy-version +1 -1
- package/.github/workflows/vona-test-mysql.yml +6 -10
- package/.github/workflows/vona-test-pg.yml +6 -10
- package/CHANGELOG.md +14 -0
- package/cabloy-docs/backend/orm-mutation-guide.md +36 -10
- package/cabloy-docs/backend/orm-select-guide.md +15 -10
- package/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +8 -124
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/sku.test.ts +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +4 -1
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +85 -2
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/cart.ts +14 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +42 -2
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +4 -3
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +160 -80
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +255 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutTransaction.test.ts +208 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +28 -4
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +90 -8
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +15 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +20 -2
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/testLock.ts +11 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_utils.ts +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +40 -32
- package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +638 -633
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { describe, it } from 'node:test';
|
|
3
|
+
import { after, before, describe, it } from 'node:test';
|
|
4
4
|
import { app } from 'vona-mock';
|
|
5
5
|
|
|
6
|
+
import { acquireTestLock } from './testLock.ts';
|
|
7
|
+
|
|
6
8
|
interface IFixture {
|
|
7
9
|
categoryId?: number;
|
|
8
10
|
productId?: number;
|
|
@@ -48,6 +50,16 @@ async function cleanup(fixture: IFixture) {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
describe('checkoutReservation.test.ts', { concurrency: false }, () => {
|
|
53
|
+
let releaseTestLock: (() => void) | undefined;
|
|
54
|
+
|
|
55
|
+
before(async () => {
|
|
56
|
+
releaseTestLock = await acquireTestLock();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
after(() => {
|
|
60
|
+
releaseTestLock?.();
|
|
61
|
+
});
|
|
62
|
+
|
|
51
63
|
it('creates an atomic checkout from the persisted cart and replays its correlation', async () => {
|
|
52
64
|
await app.bean.executor.mockCtx(async () => {
|
|
53
65
|
const fixture: IFixture = {};
|
|
@@ -155,4 +167,246 @@ describe('checkoutReservation.test.ts', { concurrency: false }, () => {
|
|
|
155
167
|
}
|
|
156
168
|
});
|
|
157
169
|
});
|
|
170
|
+
|
|
171
|
+
it('allows exactly one independently authenticated customer to checkout the final unit', async t => {
|
|
172
|
+
if (process.env.DATABASE_DEFAULT_CLIENT !== 'pg') {
|
|
173
|
+
t.skip('requires PostgreSQL row-lock contention');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const suffix = randomUUID().slice(0, 12);
|
|
178
|
+
const fixture: {
|
|
179
|
+
categoryId?: number;
|
|
180
|
+
productId?: number;
|
|
181
|
+
skuId?: number;
|
|
182
|
+
balanceId?: number;
|
|
183
|
+
customers: Array<{
|
|
184
|
+
name: string;
|
|
185
|
+
userId: number;
|
|
186
|
+
addressId: number;
|
|
187
|
+
cartId: number;
|
|
188
|
+
cartItemId: number;
|
|
189
|
+
correlationId: string;
|
|
190
|
+
}>;
|
|
191
|
+
} = { customers: [] };
|
|
192
|
+
|
|
193
|
+
const createCustomerCheckout = async (name: string, index: number) => {
|
|
194
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
195
|
+
const user = await app.bean.user.register({ name }, true);
|
|
196
|
+
const trade = app.scope('commerce-trade');
|
|
197
|
+
await app.bean.passport.signinMock(name as any);
|
|
198
|
+
try {
|
|
199
|
+
const addressId = await app.bean.executor.performAction(
|
|
200
|
+
'post',
|
|
201
|
+
'/commerce/member/address',
|
|
202
|
+
{
|
|
203
|
+
body: {
|
|
204
|
+
recipientName: `Checkout Contention Customer ${index}`,
|
|
205
|
+
phone: `1555555012${index}`,
|
|
206
|
+
countryCode: 'US',
|
|
207
|
+
region: 'California',
|
|
208
|
+
city: 'San Francisco',
|
|
209
|
+
postalCode: '94105',
|
|
210
|
+
addressLine1: `${index} Market Street`,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
const cart = await trade.model.cart.insert({ userId: user.id });
|
|
215
|
+
const cartItem = await trade.model.cartItem.insert({
|
|
216
|
+
cartId: cart.id,
|
|
217
|
+
skuId: fixture.skuId!,
|
|
218
|
+
quantity: 1,
|
|
219
|
+
});
|
|
220
|
+
return {
|
|
221
|
+
name,
|
|
222
|
+
userId: user.id as number,
|
|
223
|
+
addressId,
|
|
224
|
+
cartId: cart.id as number,
|
|
225
|
+
cartItemId: cartItem.id as number,
|
|
226
|
+
correlationId: `checkout-contention-${suffix}-${index}`,
|
|
227
|
+
};
|
|
228
|
+
} finally {
|
|
229
|
+
await app.bean.passport.signout();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
await app.bean.executor.mockCtx(async () => {
|
|
236
|
+
await app.bean.passport.signinMock();
|
|
237
|
+
try {
|
|
238
|
+
const trade = app.scope('commerce-trade');
|
|
239
|
+
fixture.categoryId = await app.bean.executor.performAction(
|
|
240
|
+
'post',
|
|
241
|
+
'/commerce/catalog/category',
|
|
242
|
+
{ body: { name: `checkout-contention-category-${suffix}`, published: true } },
|
|
243
|
+
);
|
|
244
|
+
fixture.productId = await app.bean.executor.performAction(
|
|
245
|
+
'post',
|
|
246
|
+
'/commerce/catalog/product',
|
|
247
|
+
{
|
|
248
|
+
body: {
|
|
249
|
+
categoryId: fixture.categoryId,
|
|
250
|
+
title: `checkout-contention-product-${suffix}`,
|
|
251
|
+
published: true,
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
|
|
256
|
+
body: {
|
|
257
|
+
productId: fixture.productId,
|
|
258
|
+
code: `checkout-contention-sku-${suffix}`,
|
|
259
|
+
priceCents: 1299,
|
|
260
|
+
attributes: [],
|
|
261
|
+
lifecycle: 'active',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const balance = await trade.service.stockBalance.adjustStock({
|
|
265
|
+
skuId: fixture.skuId,
|
|
266
|
+
delta: 1,
|
|
267
|
+
reason: 'checkout contention fixture',
|
|
268
|
+
correlationId: `checkout-contention-stock-${suffix}`,
|
|
269
|
+
});
|
|
270
|
+
fixture.balanceId = balance.id as number;
|
|
271
|
+
} finally {
|
|
272
|
+
await app.bean.passport.signout();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
fixture.customers.push(
|
|
277
|
+
await createCustomerCheckout(`checkout-contention-a-${suffix}`, 1),
|
|
278
|
+
await createCustomerCheckout(`checkout-contention-b-${suffix}`, 2),
|
|
279
|
+
);
|
|
280
|
+
assert.notEqual(fixture.customers[0].userId, fixture.customers[1].userId);
|
|
281
|
+
assert.notEqual(fixture.customers[0].addressId, fixture.customers[1].addressId);
|
|
282
|
+
assert.notEqual(fixture.customers[0].cartId, fixture.customers[1].cartId);
|
|
283
|
+
|
|
284
|
+
const checkoutInContext = async (customer: (typeof fixture.customers)[number]) => {
|
|
285
|
+
return await app.bean.executor.mockCtx(async () => {
|
|
286
|
+
await app.bean.passport.signinMock(customer.name as any);
|
|
287
|
+
try {
|
|
288
|
+
return await app.scope('commerce-trade').service.order.checkout({
|
|
289
|
+
addressId: customer.addressId,
|
|
290
|
+
correlationId: customer.correlationId,
|
|
291
|
+
});
|
|
292
|
+
} finally {
|
|
293
|
+
await app.bean.passport.signout();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
const results = await Promise.allSettled(fixture.customers.map(checkoutInContext));
|
|
298
|
+
assert.equal(
|
|
299
|
+
results.filter(result => result.status === 'fulfilled').length,
|
|
300
|
+
1,
|
|
301
|
+
JSON.stringify(results),
|
|
302
|
+
);
|
|
303
|
+
assert.equal(
|
|
304
|
+
results.filter(result => result.status === 'rejected').length,
|
|
305
|
+
1,
|
|
306
|
+
JSON.stringify(results),
|
|
307
|
+
);
|
|
308
|
+
const rejected = results.find(result => result.status === 'rejected');
|
|
309
|
+
assert.equal(
|
|
310
|
+
(rejected as PromiseRejectedResult | undefined)?.reason?.code,
|
|
311
|
+
409,
|
|
312
|
+
String((rejected as PromiseRejectedResult | undefined)?.reason),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const winningIndex = results.findIndex(result => result.status === 'fulfilled');
|
|
316
|
+
assert.notEqual(winningIndex, -1);
|
|
317
|
+
const winningResult = results[winningIndex];
|
|
318
|
+
assert.equal(winningResult.status, 'fulfilled');
|
|
319
|
+
if (winningResult.status !== 'fulfilled') throw new Error('expected a successful checkout');
|
|
320
|
+
const winner = fixture.customers[winningIndex];
|
|
321
|
+
const loser = fixture.customers[winningIndex === 0 ? 1 : 0];
|
|
322
|
+
|
|
323
|
+
await app.bean.executor.mockCtx(async () => {
|
|
324
|
+
const trade = app.scope('commerce-trade');
|
|
325
|
+
const payment = app.scope('commerce-payment');
|
|
326
|
+
const winningOrder = await trade.model.order.get({ correlationId: winner.correlationId });
|
|
327
|
+
const losingOrder = await trade.model.order.get({ correlationId: loser.correlationId });
|
|
328
|
+
assert.ok(winningOrder);
|
|
329
|
+
assert.equal(winningOrder.id, winningResult.value.orderId);
|
|
330
|
+
assert.equal(losingOrder, undefined);
|
|
331
|
+
|
|
332
|
+
const lines = await trade.model.orderLine.select({ where: { orderId: winningOrder.id } });
|
|
333
|
+
assert.equal(lines.length, 1);
|
|
334
|
+
assert.equal(String(lines[0].skuId), String(fixture.skuId));
|
|
335
|
+
assert.equal(lines[0].quantity, 1);
|
|
336
|
+
const reservations = await trade.model.stockReservation.select({
|
|
337
|
+
where: { skuId: fixture.skuId },
|
|
338
|
+
});
|
|
339
|
+
assert.equal(reservations.length, 1);
|
|
340
|
+
assert.equal(String(reservations[0].orderLineId), String(lines[0].id));
|
|
341
|
+
assert.equal(reservations[0].state, 'reserved');
|
|
342
|
+
|
|
343
|
+
const paymentAttempt = await payment.model.paymentAttempt.get({ orderId: winningOrder.id });
|
|
344
|
+
assert.equal(paymentAttempt?.id, winningResult.value.paymentAttemptId);
|
|
345
|
+
assert.equal(paymentAttempt?.state, 'created');
|
|
346
|
+
const orderAudits = await trade.model.orderAudit.select({
|
|
347
|
+
where: { orderId: winningOrder.id },
|
|
348
|
+
});
|
|
349
|
+
assert.deepEqual(
|
|
350
|
+
orderAudits.map(audit => audit.operation),
|
|
351
|
+
['created'],
|
|
352
|
+
);
|
|
353
|
+
const stockAudits = await trade.model.stockAudit.select({
|
|
354
|
+
where: { skuId: fixture.skuId },
|
|
355
|
+
orders: [['id', 'asc']],
|
|
356
|
+
});
|
|
357
|
+
assert.deepEqual(
|
|
358
|
+
stockAudits.map(audit => audit.operation),
|
|
359
|
+
['adjust', 'reserve'],
|
|
360
|
+
);
|
|
361
|
+
assert.equal(
|
|
362
|
+
stockAudits.some(audit => audit.correlationId === `${winner.correlationId}:line:0`),
|
|
363
|
+
true,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const balance = await trade.model.stockBalance.getById(fixture.balanceId!);
|
|
367
|
+
assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [1, 1, 0]);
|
|
368
|
+
assert.equal(await trade.model.cartItem.getById(winner.cartItemId), undefined);
|
|
369
|
+
assert.equal((await trade.model.cartItem.getById(loser.cartItemId))?.quantity, 1);
|
|
370
|
+
});
|
|
371
|
+
} finally {
|
|
372
|
+
await app.bean.executor.mockCtx(async () => {
|
|
373
|
+
const trade = app.scope('commerce-trade');
|
|
374
|
+
const payment = app.scope('commerce-payment');
|
|
375
|
+
const catalog = app.scope('commerce-catalog');
|
|
376
|
+
const member = app.scope('commerce-member');
|
|
377
|
+
for (const customer of fixture.customers) {
|
|
378
|
+
const orders = await trade.model.order.select({
|
|
379
|
+
where: { correlationId: customer.correlationId },
|
|
380
|
+
});
|
|
381
|
+
for (const order of orders) {
|
|
382
|
+
await payment.model.paymentAttempt.delete({ orderId: order.id });
|
|
383
|
+
await trade.model.orderAudit.delete({ orderId: order.id });
|
|
384
|
+
const lines = await trade.model.orderLine.select({ where: { orderId: order.id } });
|
|
385
|
+
for (const line of lines)
|
|
386
|
+
await trade.model.stockReservation.delete({ orderLineId: line.id });
|
|
387
|
+
await trade.model.orderLine.delete({ orderId: order.id });
|
|
388
|
+
await trade.model.order.delete({ id: order.id });
|
|
389
|
+
}
|
|
390
|
+
await trade.model.cartItem.delete({ cartId: customer.cartId });
|
|
391
|
+
await trade.model.cart.delete({ id: customer.cartId });
|
|
392
|
+
await member.model.address.delete({ id: customer.addressId });
|
|
393
|
+
}
|
|
394
|
+
if (fixture.skuId !== undefined) {
|
|
395
|
+
await trade.model.stockReservation.delete({ skuId: fixture.skuId });
|
|
396
|
+
await trade.model.stockAudit.delete({ skuId: fixture.skuId });
|
|
397
|
+
}
|
|
398
|
+
if (fixture.balanceId !== undefined)
|
|
399
|
+
await trade.model.stockBalance.delete({ id: fixture.balanceId });
|
|
400
|
+
if (fixture.skuId !== undefined) await catalog.model.sku.delete({ id: fixture.skuId });
|
|
401
|
+
if (fixture.productId !== undefined)
|
|
402
|
+
await catalog.model.product.delete({ id: fixture.productId });
|
|
403
|
+
if (fixture.categoryId !== undefined)
|
|
404
|
+
await catalog.model.category.delete({ id: fixture.categoryId });
|
|
405
|
+
for (const customer of fixture.customers) {
|
|
406
|
+
await app.scope('home-user').model.roleUser.delete({ userId: customer.userId });
|
|
407
|
+
await app.bean.user.removeById(customer.userId);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
});
|
|
158
412
|
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { catchError } from '@cabloy/utils';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { after, before, describe, it } from 'node:test';
|
|
5
|
+
import { app } from 'vona-mock';
|
|
6
|
+
|
|
7
|
+
import type { CheckoutFailureStage } from '../src/service/order.ts';
|
|
8
|
+
|
|
9
|
+
import { acquireTestLock } from './testLock.ts';
|
|
10
|
+
|
|
11
|
+
interface IFixture {
|
|
12
|
+
categoryId: number;
|
|
13
|
+
productId: number;
|
|
14
|
+
skuId: number;
|
|
15
|
+
balanceId: number;
|
|
16
|
+
templateId: number;
|
|
17
|
+
grantId: number;
|
|
18
|
+
addressId: number;
|
|
19
|
+
cartId: number;
|
|
20
|
+
cartItemId: number;
|
|
21
|
+
userId: number;
|
|
22
|
+
correlationId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function createFixture(suffix: string): Promise<IFixture> {
|
|
26
|
+
const userId = app.bean.passport.currentUser!.id as number;
|
|
27
|
+
const categoryId = await app.bean.executor.performAction('post', '/commerce/catalog/category', {
|
|
28
|
+
body: { name: `checkout-txn-category-${suffix}`, published: true },
|
|
29
|
+
});
|
|
30
|
+
const productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
|
|
31
|
+
body: { categoryId, title: `checkout-txn-product-${suffix}`, published: true },
|
|
32
|
+
});
|
|
33
|
+
const skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
|
|
34
|
+
body: {
|
|
35
|
+
productId,
|
|
36
|
+
code: `checkout-txn-sku-${suffix}`,
|
|
37
|
+
priceCents: 1299,
|
|
38
|
+
attributes: [],
|
|
39
|
+
lifecycle: 'active',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const balance = await app.scope('commerce-trade').service.stockBalance.adjustStock({
|
|
43
|
+
skuId,
|
|
44
|
+
delta: 1,
|
|
45
|
+
reason: 'checkout transaction fixture',
|
|
46
|
+
correlationId: `checkout-txn-stock-${suffix}`,
|
|
47
|
+
});
|
|
48
|
+
const template = await app.scope('commerce-promotion').model.couponTemplate.insert({
|
|
49
|
+
name: `checkout-txn-coupon-${suffix}`,
|
|
50
|
+
state: 'active',
|
|
51
|
+
currency: 'USD',
|
|
52
|
+
discountCents: 500,
|
|
53
|
+
minSpendCents: 1000,
|
|
54
|
+
validFrom: new Date(Date.now() - 1_000),
|
|
55
|
+
validUntil: new Date(Date.now() + 60_000),
|
|
56
|
+
issuedCount: 0,
|
|
57
|
+
redeemedCount: 0,
|
|
58
|
+
});
|
|
59
|
+
const grant = await app.scope('commerce-promotion').service.coupon.issue({
|
|
60
|
+
templateId: template.id,
|
|
61
|
+
userId,
|
|
62
|
+
correlationId: `checkout-txn-coupon-${suffix}`,
|
|
63
|
+
reason: 'checkout transaction fixture',
|
|
64
|
+
});
|
|
65
|
+
const addressId = await app.bean.executor.performAction('post', '/commerce/member/address', {
|
|
66
|
+
body: {
|
|
67
|
+
recipientName: 'Checkout Transaction Customer',
|
|
68
|
+
phone: '15555550125',
|
|
69
|
+
countryCode: 'US',
|
|
70
|
+
region: 'California',
|
|
71
|
+
city: 'San Francisco',
|
|
72
|
+
postalCode: '94105',
|
|
73
|
+
addressLine1: '3 Market Street',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const cart = await app.scope('commerce-trade').model.cart.insert({ userId });
|
|
77
|
+
const cartItem = await app.scope('commerce-trade').model.cartItem.insert({
|
|
78
|
+
cartId: cart.id,
|
|
79
|
+
skuId,
|
|
80
|
+
quantity: 1,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
categoryId,
|
|
84
|
+
productId,
|
|
85
|
+
skuId,
|
|
86
|
+
balanceId: balance.id as number,
|
|
87
|
+
templateId: template.id as number,
|
|
88
|
+
grantId: grant.id as number,
|
|
89
|
+
addressId,
|
|
90
|
+
cartId: cart.id as number,
|
|
91
|
+
cartItemId: cartItem.id as number,
|
|
92
|
+
userId,
|
|
93
|
+
correlationId: `checkout-txn-${suffix}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function cleanup(fixture: IFixture) {
|
|
98
|
+
const trade = app.scope('commerce-trade');
|
|
99
|
+
const payment = app.scope('commerce-payment');
|
|
100
|
+
const promotion = app.scope('commerce-promotion');
|
|
101
|
+
const catalog = app.scope('commerce-catalog');
|
|
102
|
+
const member = app.scope('commerce-member');
|
|
103
|
+
const orders = await trade.model.order.select({
|
|
104
|
+
where: { correlationId: fixture.correlationId },
|
|
105
|
+
});
|
|
106
|
+
for (const order of orders) {
|
|
107
|
+
await payment.model.paymentAttempt.delete({ orderId: order.id });
|
|
108
|
+
await trade.model.orderAudit.delete({ orderId: order.id });
|
|
109
|
+
const lines = await trade.model.orderLine.select({ where: { orderId: order.id } });
|
|
110
|
+
for (const line of lines) await trade.model.stockReservation.delete({ orderLineId: line.id });
|
|
111
|
+
await trade.model.orderLine.delete({ orderId: order.id });
|
|
112
|
+
await trade.model.order.delete({ id: order.id });
|
|
113
|
+
}
|
|
114
|
+
await promotion.model.couponAudit.delete({ couponGrantId: fixture.grantId });
|
|
115
|
+
await promotion.model.couponGrant.delete({ id: fixture.grantId });
|
|
116
|
+
await promotion.model.couponTemplate.delete({ id: fixture.templateId });
|
|
117
|
+
await trade.model.cartItem.delete({ cartId: fixture.cartId });
|
|
118
|
+
await trade.model.cart.delete({ id: fixture.cartId });
|
|
119
|
+
await trade.model.stockAudit.delete({ skuId: fixture.skuId });
|
|
120
|
+
await trade.model.stockReservation.delete({ skuId: fixture.skuId });
|
|
121
|
+
await trade.model.stockBalance.delete({ id: fixture.balanceId });
|
|
122
|
+
await catalog.model.sku.delete({ id: fixture.skuId });
|
|
123
|
+
await catalog.model.product.delete({ id: fixture.productId });
|
|
124
|
+
await catalog.model.category.delete({ id: fixture.categoryId });
|
|
125
|
+
await member.model.address.delete({ id: fixture.addressId });
|
|
126
|
+
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
127
|
+
await app.bean.user.removeById(fixture.userId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function assertRollback(fixture: IFixture) {
|
|
131
|
+
const trade = app.scope('commerce-trade');
|
|
132
|
+
const promotion = app.scope('commerce-promotion');
|
|
133
|
+
assert.equal(await trade.model.order.get({ correlationId: fixture.correlationId }), undefined);
|
|
134
|
+
assert.equal(await trade.model.stockReservation.get({ skuId: fixture.skuId }), undefined);
|
|
135
|
+
const cartItem = await trade.model.cartItem.getById(fixture.cartItemId);
|
|
136
|
+
assert.equal(cartItem?.quantity, 1);
|
|
137
|
+
const balance = await trade.model.stockBalance.getById(fixture.balanceId);
|
|
138
|
+
assert.deepEqual([balance?.onHand, balance?.reserved, balance?.available], [1, 0, 1]);
|
|
139
|
+
const stockAudits = await trade.model.stockAudit.select({ where: { skuId: fixture.skuId } });
|
|
140
|
+
assert.equal(stockAudits.filter(audit => audit.operation === 'reserve').length, 0);
|
|
141
|
+
const grant = await promotion.model.couponGrant.getById(fixture.grantId);
|
|
142
|
+
assert.equal(grant?.state, 'available');
|
|
143
|
+
assert.equal(grant?.reservationOrderId, undefined);
|
|
144
|
+
const couponAudits = await promotion.model.couponAudit.select({
|
|
145
|
+
where: { couponGrantId: fixture.grantId },
|
|
146
|
+
});
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
couponAudits.map(audit => audit.operation),
|
|
149
|
+
['issue'],
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('checkoutTransaction.test.ts', { concurrency: false, sequential: true }, () => {
|
|
154
|
+
let releaseTestLock: (() => void) | undefined;
|
|
155
|
+
|
|
156
|
+
before(async () => {
|
|
157
|
+
releaseTestLock = await acquireTestLock();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
after(() => {
|
|
161
|
+
releaseTestLock?.();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
for (const stage of [
|
|
165
|
+
'afterOrderSnapshot',
|
|
166
|
+
'afterCouponReservation',
|
|
167
|
+
'afterStockReservation',
|
|
168
|
+
'afterPaymentAttempt',
|
|
169
|
+
'afterOrderAudit',
|
|
170
|
+
'afterCartMutation',
|
|
171
|
+
] satisfies CheckoutFailureStage[]) {
|
|
172
|
+
it(`rolls back every checkout write when ${stage} fails`, async () => {
|
|
173
|
+
await app.bean.executor.mockCtx(async () => {
|
|
174
|
+
const suffix = randomUUID().slice(0, 12);
|
|
175
|
+
const customerName = `checkout-txn-customer-${suffix}`;
|
|
176
|
+
await app.bean.user.register({ name: customerName }, true);
|
|
177
|
+
await app.bean.passport.signinMock(customerName as any);
|
|
178
|
+
let fixture: IFixture | undefined;
|
|
179
|
+
try {
|
|
180
|
+
fixture = await createFixture(suffix);
|
|
181
|
+
const [_, error] = await catchError(() =>
|
|
182
|
+
app.scope('commerce-trade').service.order.checkoutForTest(
|
|
183
|
+
{
|
|
184
|
+
addressId: fixture.addressId,
|
|
185
|
+
couponGrantId: fixture.grantId,
|
|
186
|
+
correlationId: fixture.correlationId,
|
|
187
|
+
},
|
|
188
|
+
async currentStage => {
|
|
189
|
+
if (currentStage === stage) throw new Error(`checkout test failure: ${stage}`);
|
|
190
|
+
},
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
assert.match(error?.message ?? '', new RegExp(stage));
|
|
194
|
+
await assertRollback(fixture);
|
|
195
|
+
const result = await app.scope('commerce-trade').service.order.checkout({
|
|
196
|
+
addressId: fixture.addressId,
|
|
197
|
+
couponGrantId: fixture.grantId,
|
|
198
|
+
correlationId: fixture.correlationId,
|
|
199
|
+
});
|
|
200
|
+
assert.equal(result.state, 'awaiting_payment');
|
|
201
|
+
} finally {
|
|
202
|
+
if (fixture) await cleanup(fixture);
|
|
203
|
+
await app.bean.passport.signout();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { catchError } from '@cabloy/utils';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { after, before, describe, it } from 'node:test';
|
|
4
5
|
import { app } from 'vona-mock';
|
|
5
6
|
|
|
7
|
+
import { acquireTestLock } from './testLock.ts';
|
|
8
|
+
|
|
6
9
|
interface IFixture {
|
|
7
10
|
categoryId?: number;
|
|
8
11
|
productId?: number;
|
|
@@ -11,6 +14,7 @@ interface IFixture {
|
|
|
11
14
|
orderId?: number;
|
|
12
15
|
couponTemplateId?: number;
|
|
13
16
|
couponGrantId?: number;
|
|
17
|
+
userId?: number;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
async function cleanup(fixture: IFixture) {
|
|
@@ -44,13 +48,27 @@ async function cleanup(fixture: IFixture) {
|
|
|
44
48
|
if (fixture.categoryId !== undefined)
|
|
45
49
|
await catalog.model.category.delete({ id: fixture.categoryId });
|
|
46
50
|
if (fixture.addressId !== undefined) await member.model.address.delete({ id: fixture.addressId });
|
|
51
|
+
if (fixture.userId !== undefined) {
|
|
52
|
+
await app.scope('home-user').model.roleUser.delete({ userId: fixture.userId });
|
|
53
|
+
await app.bean.user.removeById(fixture.userId);
|
|
54
|
+
}
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
describe('orderSnapshot.test.ts', { concurrency: false }, () => {
|
|
58
|
+
let releaseTestLock: (() => void) | undefined;
|
|
59
|
+
|
|
60
|
+
before(async () => {
|
|
61
|
+
releaseTestLock = await acquireTestLock();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
after(() => {
|
|
65
|
+
releaseTestLock?.();
|
|
66
|
+
});
|
|
67
|
+
|
|
50
68
|
it('persists catalog and address facts independently from later source changes', async () => {
|
|
51
69
|
await app.bean.executor.mockCtx(async () => {
|
|
52
70
|
const fixture: IFixture = {};
|
|
53
|
-
const suffix =
|
|
71
|
+
const suffix = randomUUID().slice(0, 12);
|
|
54
72
|
try {
|
|
55
73
|
await app.bean.passport.signinMock();
|
|
56
74
|
fixture.categoryId = await app.bean.executor.performAction(
|
|
@@ -87,7 +105,14 @@ describe('orderSnapshot.test.ts', { concurrency: false }, () => {
|
|
|
87
105
|
correlationId: `snapshot-stock-${suffix}`,
|
|
88
106
|
});
|
|
89
107
|
await app.bean.passport.signout();
|
|
90
|
-
|
|
108
|
+
|
|
109
|
+
const customerName = `snapshot-customer-${suffix}`;
|
|
110
|
+
const customer = await app.bean.user.register({ name: customerName }, true);
|
|
111
|
+
fixture.userId = customer.id as number;
|
|
112
|
+
await app.bean.passport.signinMock(customerName as any);
|
|
113
|
+
const customerId = app.bean.passport.currentUser!.id;
|
|
114
|
+
assert.equal(String(customerId), String(fixture.userId));
|
|
115
|
+
|
|
91
116
|
fixture.couponTemplateId = (
|
|
92
117
|
await app.scope('commerce-promotion').model.couponTemplate.insert({
|
|
93
118
|
name: `snapshot coupon ${suffix}`,
|
|
@@ -101,7 +126,6 @@ describe('orderSnapshot.test.ts', { concurrency: false }, () => {
|
|
|
101
126
|
redeemedCount: 0,
|
|
102
127
|
})
|
|
103
128
|
).id as number;
|
|
104
|
-
const customerId = app.bean.passport.currentUser!.id;
|
|
105
129
|
fixture.couponGrantId = (
|
|
106
130
|
await app.scope('commerce-promotion').service.coupon.issue({
|
|
107
131
|
templateId: fixture.couponTemplateId,
|