feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View 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)/,
);
}
});
});

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