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

@@ -245,11 +245,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
const pricing = createPricingService(deps.pool);
await app.register(async (instance) => {
await registerCatalogRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
logger,
pricing,
});
});
@@ -261,7 +263,6 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
const pricing = createPricingService(deps.pool);
await app.register(async (instance) => {
await registerPricingRoutes(instance, {
pool: deps.pool as pg.Pool,

View File

@@ -209,6 +209,43 @@ describe.skipIf(!hasDb)('catalog product flows (real PostgreSQL)', () => {
expect(duplicateEan.statusCode).toBe(409);
});
it('seeds a default price row immediately after variant creation (F-138 AC1, AC2, AC4)', async () => {
const productRes = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
name: 'Producto Semilla F-138',
slug: 'semilla-f138-ac1',
categoryIds: [categoryId],
},
});
expect(productRes.statusCode).toBe(201);
const productId = (productRes.json() as { id: string }).id;
const variantRes = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku: 'SKU-SEMILLA-F138', ean: '8412345678902', attributes: { size: '500ml' } },
});
expect(variantRes.statusCode).toBe(201);
const variantId = (variantRes.json() as { id: string }).id;
// F-138 AC1/AC2: a default price row exists immediately after variant creation
// (no 404 window on GET /pricing/variants/:id) and re-seeding is a no-op.
const priceRow = await pool.query(
'SELECT net_unit_amount_cents, vat_rate, currency FROM pricing_variant_prices WHERE variant_id = $1',
[variantId],
);
expect(priceRow.rows[0]).toBeDefined();
expect(priceRow.rows[0]!.net_unit_amount_cents).toBe(0);
expect(priceRow.rows[0]!.vat_rate).toBe('general');
expect(priceRow.rows[0]!.currency).toBe('EUR');
});
it('stores nutrition provenance and protects manual nutrition from external overwrite (F-010 AC2, AC3)', async () => {
const manual = await app.inject({
method: 'PATCH',

View File

@@ -37,6 +37,7 @@ const pricing: PricingServicePort = {
setVariantPrice: async () => {
throw new Error('not needed');
},
seedVariantPrice: async () => undefined,
calculate: async ({ variantId, quantity }) => ({
variantId,
quantity,

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

View File

@@ -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: {

View File

@@ -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 {

View File

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

View File

@@ -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 {

View File

@@ -19,6 +19,7 @@ function repository(overrides: Partial<PricingRepository> = {}): PricingReposito
return {
findByVariantId: async () => PRICE,
setVariantPrice: async (_input: SetVariantPriceCommand) => PRICE,
seedVariantPrice: async () => undefined,
...overrides,
};
}