feat(F-138): completed feature
This commit is contained in:
@@ -37,6 +37,7 @@ const pricing: PricingServicePort = {
|
||||
setVariantPrice: async () => {
|
||||
throw new Error('not needed');
|
||||
},
|
||||
seedVariantPrice: async () => undefined,
|
||||
calculate: async ({ variantId, quantity }) => ({
|
||||
variantId,
|
||||
quantity,
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { Product } from '../domain/product.js';
|
||||
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
|
||||
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
|
||||
import { NUTRITION_SOURCES } from '../domain/variant.js';
|
||||
import type { PricingServicePort } from '../../pricing/index.js';
|
||||
import { LocalProductImageStorage } from '../infrastructure/local-product-image-storage.js';
|
||||
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
|
||||
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
|
||||
@@ -56,6 +57,7 @@ export interface CatalogRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
logger?: CatalogSearchLogger;
|
||||
pricing: PricingServicePort;
|
||||
}
|
||||
|
||||
const slugSchema = z
|
||||
@@ -178,7 +180,7 @@ export async function registerCatalogRoutes(
|
||||
const createProduct = new CreateProduct(repository);
|
||||
const updateProduct = new UpdateProduct(repository);
|
||||
const listVariants = new ListProductVariants(variants);
|
||||
const createVariant = new CreateProductVariant(repository, variants);
|
||||
const createVariant = new CreateProductVariant(repository, variants, deps.pricing);
|
||||
const updateVariant = new UpdateProductVariant(variants);
|
||||
const upsertRichData = new UpsertProductRichData(repository, richData);
|
||||
const listImages = new ListProductImages(images);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ProductRichDataRepository,
|
||||
ProductVariantRepository,
|
||||
} from '../domain/ports.js';
|
||||
import type { PricingServicePort } from '../../pricing/index.js';
|
||||
import type {
|
||||
NewProductVariant,
|
||||
ProductRichData,
|
||||
@@ -23,13 +24,23 @@ export class CreateProductVariant {
|
||||
constructor(
|
||||
private readonly products: ProductRepository,
|
||||
private readonly variants: ProductVariantRepository,
|
||||
private readonly pricing: PricingServicePort,
|
||||
) {}
|
||||
|
||||
async execute(productId: string, input: NewProductVariant): Promise<ProductVariant | undefined> {
|
||||
if (!(await this.products.findById(productId))) {
|
||||
return undefined;
|
||||
}
|
||||
return this.variants.create(productId, input);
|
||||
const variant = await this.variants.create(productId, input);
|
||||
// Best-effort: seed a default price row immediately so GET /pricing/variants/:id
|
||||
// never 404s after variant creation (F-138). The variant already persisted, so a
|
||||
// seed failure must not fail or roll back variant creation itself.
|
||||
try {
|
||||
await this.pricing.seedVariantPrice(variant.id);
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
126
project/src/modules/catalog/tests/variant-use-cases.test.ts
Normal file
126
project/src/modules/catalog/tests/variant-use-cases.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateProductVariant } from '../application/variant-use-cases.js';
|
||||
import type { PricingServicePort, PriceCalculation, VariantPrice } from '../../pricing/index.js';
|
||||
import type { NewProductVariant, ProductVariant } from '../domain/variant.js';
|
||||
import type { Product } from '../domain/product.js';
|
||||
import type {
|
||||
ProductRepository,
|
||||
ProductVariantRepository,
|
||||
} from '../domain/ports.js';
|
||||
|
||||
function makeVariant(productId: string, sku: string): ProductVariant {
|
||||
return {
|
||||
id: 'variant-1',
|
||||
productId,
|
||||
sku,
|
||||
ean: null,
|
||||
attributes: {},
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
}
|
||||
|
||||
class FakeProductRepository implements ProductRepository {
|
||||
public readonly existing = new Set<string>();
|
||||
async findById(id: string): Promise<Product | undefined> {
|
||||
return this.existing.has(id) ? ({ id } as unknown as Product) : undefined;
|
||||
}
|
||||
async create(): Promise<Product> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async findActiveBySlug(): Promise<Product | undefined> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async update(): Promise<Product | undefined> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async listAll(): Promise<{ items: Product[]; total: number }> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async categoriesExist(): Promise<boolean> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async brandExists(): Promise<boolean> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
class FakeProductVariantRepository implements ProductVariantRepository {
|
||||
public lastInput: NewProductVariant | undefined;
|
||||
constructor(public readonly variant: ProductVariant = makeVariant('p', 'SKU-X')) {}
|
||||
async create(_productId: string, input: NewProductVariant): Promise<ProductVariant> {
|
||||
this.lastInput = input;
|
||||
return this.variant;
|
||||
}
|
||||
async listByProductId(): Promise<ProductVariant[]> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async update(): Promise<ProductVariant | undefined> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
class FakePricingService implements PricingServicePort {
|
||||
public seedCalls: string[] = [];
|
||||
public seedShouldThrow = false;
|
||||
async seedVariantPrice(variantId: string): Promise<void> {
|
||||
this.seedCalls.push(variantId);
|
||||
if (this.seedShouldThrow) throw new Error('seed boom');
|
||||
}
|
||||
async calculate(): Promise<PriceCalculation> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async getVariantPrice(): Promise<VariantPrice | undefined> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
async setVariantPrice(): Promise<VariantPrice> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
describe('CreateProductVariant', () => {
|
||||
it('seeds a default price row immediately after creating the variant (F-138 AC1, AC4)', async () => {
|
||||
const products = new FakeProductRepository();
|
||||
products.existing.add('product-1');
|
||||
const variant = makeVariant('product-1', 'SKU-X');
|
||||
const variants = new FakeProductVariantRepository(variant);
|
||||
const pricing = new FakePricingService();
|
||||
const usecase = new CreateProductVariant(products, variants, pricing);
|
||||
|
||||
const result = await usecase.execute('product-1', { sku: 'SKU-X' });
|
||||
|
||||
expect(result).toBe(variant);
|
||||
expect(variants.lastInput?.sku).toBe('SKU-X');
|
||||
expect(pricing.seedCalls).toEqual(['variant-1']);
|
||||
});
|
||||
|
||||
it('does not seed a price row when the product does not exist (F-138 AC1)', async () => {
|
||||
const products = new FakeProductRepository();
|
||||
const variants = new FakeProductVariantRepository();
|
||||
const pricing = new FakePricingService();
|
||||
const usecase = new CreateProductVariant(products, variants, pricing);
|
||||
|
||||
const result = await usecase.execute('product-2', { sku: 'SKU-X' });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(pricing.seedCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the created variant even if seeding the price row fails (F-138 AC3, best-effort)', async () => {
|
||||
const products = new FakeProductRepository();
|
||||
products.existing.add('product-1');
|
||||
const variant = makeVariant('product-1', 'SKU-X');
|
||||
const variants = new FakeProductVariantRepository(variant);
|
||||
const pricing = new FakePricingService();
|
||||
pricing.seedShouldThrow = true;
|
||||
const usecase = new CreateProductVariant(products, variants, pricing);
|
||||
|
||||
const result = await usecase.execute('product-1', { sku: 'SKU-X' });
|
||||
|
||||
expect(result).toBe(variant);
|
||||
expect(pricing.seedCalls).toEqual(['variant-1']);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function buildDeps(
|
||||
const pricing: PricingServicePort = {
|
||||
getVariantPrice: async () => undefined,
|
||||
setVariantPrice: async () => ({}) as never,
|
||||
seedVariantPrice: async () => undefined,
|
||||
calculate: async () => PRICE,
|
||||
};
|
||||
const reserved = { calls: [] as Array<{ variantId: string; quantity: number }> };
|
||||
@@ -234,6 +235,7 @@ describe('CheckoutService', () => {
|
||||
pricing: {
|
||||
getVariantPrice: async () => undefined,
|
||||
setVariantPrice: async () => ({}) as never,
|
||||
seedVariantPrice: async () => undefined,
|
||||
calculate: async () => PRICE,
|
||||
},
|
||||
inventory: {
|
||||
|
||||
@@ -42,6 +42,10 @@ export class PricingService implements PricingServicePort {
|
||||
ensureNonNegativeInteger(input.netUnitAmountCents, 'Price must be a non-negative integer');
|
||||
return this.repository.setVariantPrice(input);
|
||||
}
|
||||
|
||||
async seedVariantPrice(variantId: string): Promise<void> {
|
||||
await this.repository.seedVariantPrice(variantId);
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePositiveInteger(value: number, message: string): void {
|
||||
|
||||
@@ -9,9 +9,16 @@ export interface PricingService {
|
||||
calculate(input: PriceCalculationCommand): Promise<PriceCalculation>;
|
||||
getVariantPrice(variantId: string): Promise<VariantPrice | undefined>;
|
||||
setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice>;
|
||||
/**
|
||||
* Seeds a neutral default price row for a freshly created variant so that
|
||||
* GET /pricing/variants/:id never 404s right after creation (F-138). Idempotent:
|
||||
* no-op when a row already exists for the given variant_id.
|
||||
*/
|
||||
seedVariantPrice(variantId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PricingRepository {
|
||||
findByVariantId(variantId: string): Promise<VariantPrice | undefined>;
|
||||
setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice>;
|
||||
seedVariantPrice(variantId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ export class PgPricingRepository implements PricingRepository {
|
||||
return row ? toVariantPrice(row) : undefined;
|
||||
}
|
||||
|
||||
async seedVariantPrice(variantId: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, offer_cents, cost_cents, vat_rate)
|
||||
VALUES ($1, 0, NULL, NULL, 'general')
|
||||
ON CONFLICT (variant_id) DO NOTHING`,
|
||||
[variantId],
|
||||
);
|
||||
}
|
||||
|
||||
async setVariantPrice(input: SetVariantPriceCommand): Promise<VariantPrice> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ function repository(overrides: Partial<PricingRepository> = {}): PricingReposito
|
||||
return {
|
||||
findByVariantId: async () => PRICE,
|
||||
setVariantPrice: async (_input: SetVariantPriceCommand) => PRICE,
|
||||
seedVariantPrice: async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user