feat(ADM-018): completed feature
This commit is contained in:
34
project/src/modules/checkout/tests/boundary.test.ts
Normal file
34
project/src/modules/checkout/tests/boundary.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const SQL_TABLES = /\bcart_|\binventory_|\bpricing_|\bshipping_|\bpromotions_|\busers_/;
|
||||
|
||||
describe('checkout persistence boundary', () => {
|
||||
it('does not reference other module tables in code or SQL', () => {
|
||||
const dir = new URL('..', import.meta.url);
|
||||
for (const file of sourceFiles(dir.pathname)) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
// Allow imports of public interfaces from sibling modules but not
|
||||
// hardcoded SQL table references.
|
||||
expect(source).not.toMatch(SQL_TABLES);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not import internals of sibling modules', () => {
|
||||
const dir = new URL('..', import.meta.url);
|
||||
for (const file of sourceFiles(dir.pathname)) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toMatch(
|
||||
/modules\/(cart|inventory|pricing|promotions|shipping|orders)\/(api|application|infrastructure|tests)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal file
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CheckoutService } from '../application/checkout-service.js';
|
||||
import { CheckoutError } from '../domain/checkout.js';
|
||||
import type { CheckoutOrderLookup, PaymentProvider } from '../domain/ports.js';
|
||||
import { InMemoryCheckoutMetrics } from '../infrastructure/metrics.js';
|
||||
import { StubPaymentProvider } from '../infrastructure/payment-provider.js';
|
||||
import type { InventoryServicePort } from '../../inventory/index.js';
|
||||
import type {
|
||||
CreateOrderCommand,
|
||||
OrderItemInput,
|
||||
OrderServicePort,
|
||||
OrderState,
|
||||
OrderView,
|
||||
} from '../../orders/index.js';
|
||||
import type { PricingServicePort } from '../../pricing/index.js';
|
||||
import type { ShippingServicePort } from '../../shipping/index.js';
|
||||
|
||||
const PRICE = {
|
||||
variantId: 'v-1',
|
||||
quantity: 1,
|
||||
currency: 'EUR' as const,
|
||||
vatRate: 'general' as const,
|
||||
vatBasisPoints: 2100,
|
||||
netUnitAmountCents: 1000,
|
||||
netSubtotalCents: 1000,
|
||||
vatAmountCents: 210,
|
||||
totalCents: 1210,
|
||||
};
|
||||
|
||||
interface Deps {
|
||||
service: CheckoutService;
|
||||
metrics: InMemoryCheckoutMetrics;
|
||||
reserved: { calls: Array<{ variantId: string; quantity: number }> };
|
||||
released: { calls: Array<{ variantId: string; quantity: number }> };
|
||||
cancelCalls: Array<{ id: string; state: OrderState }>;
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
overrides: Partial<{
|
||||
stock: boolean;
|
||||
promo: boolean;
|
||||
shipping: boolean;
|
||||
orderId: string;
|
||||
idempotency?: { orderId: string };
|
||||
}> = {},
|
||||
): Deps {
|
||||
const cart = {
|
||||
getCart: async () => ({
|
||||
userId: 'u-1',
|
||||
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
|
||||
}),
|
||||
};
|
||||
const pricing: PricingServicePort = {
|
||||
getVariantPrice: async () => undefined,
|
||||
setVariantPrice: async () => ({}) as never,
|
||||
calculate: async () => PRICE,
|
||||
};
|
||||
const reserved = { calls: [] as Array<{ variantId: string; quantity: number }> };
|
||||
const released = { calls: [] as Array<{ variantId: string; quantity: number }> };
|
||||
const cancelCalls: Array<{ id: string; state: OrderState }> = [];
|
||||
const inventory: InventoryServicePort = {
|
||||
checkAvailability: async () => ({ available: overrides.stock ?? true, availableQuantity: 10 }),
|
||||
reserve: async (input) => {
|
||||
reserved.calls.push(input);
|
||||
return { ...PRICE } as never;
|
||||
},
|
||||
release: async (input) => {
|
||||
released.calls.push(input);
|
||||
return { ...PRICE } as never;
|
||||
},
|
||||
confirm: async () => ({}) as never,
|
||||
setAvailable: async () => ({}) as never,
|
||||
};
|
||||
const shipping: ShippingServicePort = {
|
||||
calculate: async () =>
|
||||
overrides.shipping === false
|
||||
? Promise.reject(
|
||||
Object.assign(new Error('not found'), { name: 'ShippingZoneNotFoundError' }),
|
||||
)
|
||||
: Promise.resolve({
|
||||
zoneId: 'z-1',
|
||||
methodId: 'm-1',
|
||||
methodName: 'Standard',
|
||||
costCents: 500,
|
||||
freeApplied: false,
|
||||
}),
|
||||
};
|
||||
const orderId = overrides.orderId ?? 'order-1';
|
||||
const orders: OrderServicePort = {
|
||||
create: async (input: CreateOrderCommand) => ({
|
||||
id: orderId,
|
||||
userId: input.userId,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
state: 'AWAITING_PAYMENT',
|
||||
currency: 'EUR',
|
||||
subtotalCents: input.totals.subtotalCents,
|
||||
discountCents: input.totals.discountCents,
|
||||
taxCents: input.totals.taxCents,
|
||||
totalCents: input.totals.totalCents,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: input.items.map((item: OrderItemInput, index: number) => ({
|
||||
id: `i-${index}`,
|
||||
orderId,
|
||||
productId: item.productId,
|
||||
variantId: item.variantId,
|
||||
sku: item.sku,
|
||||
ean: null,
|
||||
name: item.name,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
discountCents: item.discountCents,
|
||||
taxCents: item.taxCents,
|
||||
quantity: item.quantity,
|
||||
createdAt: new Date(),
|
||||
})),
|
||||
}),
|
||||
listOrders: async () => [],
|
||||
transition: async (id, state) => {
|
||||
cancelCalls.push({ id, state });
|
||||
return {
|
||||
id,
|
||||
userId: 'u-1',
|
||||
state,
|
||||
currency: 'EUR',
|
||||
idempotencyKey: 'k-1',
|
||||
subtotalCents: 1210,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
totalCents: 1710,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: [],
|
||||
} satisfies OrderView;
|
||||
},
|
||||
getOrder: async (id) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
state: 'AWAITING_PAYMENT',
|
||||
currency: 'EUR',
|
||||
idempotencyKey: 'k-1',
|
||||
subtotalCents: 1210,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
totalCents: 1710,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: [],
|
||||
}),
|
||||
getOrderAdmin: async (id) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
state: 'AWAITING_PAYMENT',
|
||||
currency: 'EUR',
|
||||
idempotencyKey: 'k-1',
|
||||
subtotalCents: 1210,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
totalCents: 1710,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: [],
|
||||
}),
|
||||
transitionAdmin: async (id, state) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
state,
|
||||
currency: 'EUR',
|
||||
idempotencyKey: 'k-1',
|
||||
subtotalCents: 1210,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
totalCents: 1710,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: [],
|
||||
}),
|
||||
};
|
||||
const orderLookup: CheckoutOrderLookup = { findByIdempotency: async () => overrides.idempotency };
|
||||
const payments: PaymentProvider = new StubPaymentProvider();
|
||||
const metrics = new InMemoryCheckoutMetrics();
|
||||
const service = new CheckoutService({
|
||||
cart: cart as never,
|
||||
pricing,
|
||||
inventory,
|
||||
shipping,
|
||||
orders,
|
||||
payments,
|
||||
orderLookup,
|
||||
metrics,
|
||||
});
|
||||
return { service, metrics, reserved, released, cancelCalls };
|
||||
}
|
||||
|
||||
describe('CheckoutService', () => {
|
||||
it('returns 409 when stock is unavailable (AC1)', async () => {
|
||||
const { service } = buildDeps({ stock: false });
|
||||
await expect(
|
||||
service.execute({
|
||||
userId: 'u-1',
|
||||
address: { country: 'ES', postalCode: '28001' },
|
||||
idempotencyKey: 'k-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CheckoutError);
|
||||
});
|
||||
|
||||
it('creates an AWAITING_PAYMENT order with reserved stock on success', async () => {
|
||||
const deps = buildDeps();
|
||||
const result = await deps.service.execute({
|
||||
userId: 'u-1',
|
||||
address: { country: 'ES', postalCode: '28001' },
|
||||
idempotencyKey: 'k-1',
|
||||
});
|
||||
expect(result.order.state).toBe('AWAITING_PAYMENT');
|
||||
expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]);
|
||||
expect(deps.metrics.success).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the same order on idempotent retry without reserving twice (AC2)', async () => {
|
||||
const dep2 = buildDeps();
|
||||
const lookup: CheckoutOrderLookup = { findByIdempotency: async () => ({ orderId: 'order-x' }) };
|
||||
const service2 = new CheckoutService({
|
||||
cart: {
|
||||
getCart: async () => ({
|
||||
userId: 'u-1',
|
||||
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
|
||||
}),
|
||||
} as never,
|
||||
pricing: {
|
||||
getVariantPrice: async () => undefined,
|
||||
setVariantPrice: async () => ({}) as never,
|
||||
calculate: async () => PRICE,
|
||||
},
|
||||
inventory: {
|
||||
checkAvailability: async () => ({ available: true, availableQuantity: 10 }),
|
||||
reserve: async (i) => {
|
||||
dep2.reserved.calls.push(i);
|
||||
return {} as never;
|
||||
},
|
||||
release: async () => ({}) as never,
|
||||
confirm: async () => ({}) as never,
|
||||
setAvailable: async () => ({}) as never,
|
||||
},
|
||||
shipping: {
|
||||
calculate: async () => ({
|
||||
zoneId: 'z',
|
||||
methodId: 'm',
|
||||
methodName: 'Standard',
|
||||
costCents: 0,
|
||||
freeApplied: true,
|
||||
}),
|
||||
},
|
||||
orders: {
|
||||
create: async () => {
|
||||
throw new Error('should not create on retry');
|
||||
},
|
||||
transition: async () => {
|
||||
throw new Error('should not transition on retry');
|
||||
},
|
||||
listOrders: async () => [],
|
||||
getOrderAdmin: async () => { throw new Error('not used'); },
|
||||
transitionAdmin: async () => { throw new Error('not used'); },
|
||||
getOrder: async (id) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
state: 'AWAITING_PAYMENT',
|
||||
currency: 'EUR',
|
||||
idempotencyKey: 'k-1',
|
||||
subtotalCents: 1210,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
totalCents: 1710,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
items: [
|
||||
{
|
||||
id: 'i-1',
|
||||
orderId: id,
|
||||
productId: 'p-1',
|
||||
variantId: 'v-1',
|
||||
sku: 'v-1',
|
||||
ean: null,
|
||||
name: 'x',
|
||||
unitPriceCents: 1000,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
quantity: 1,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as OrderServicePort,
|
||||
payments: new StubPaymentProvider(),
|
||||
orderLookup: lookup,
|
||||
metrics: dep2.metrics,
|
||||
});
|
||||
const second = await service2.execute({
|
||||
userId: 'u-1',
|
||||
address: { country: 'ES', postalCode: '28001' },
|
||||
idempotencyKey: 'k-1',
|
||||
});
|
||||
expect(second.order.id).toBe('order-x');
|
||||
expect(dep2.reserved.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user