feat(F-138): completed feature

This commit is contained in:
chattie
2026-08-22 10:18:37 +02:00
parent 5098723284
commit fb015932b2
23 changed files with 502 additions and 102 deletions

View File

@@ -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);

View File

@@ -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;
}
}

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