Sesión TDD completa · Cliente: LeadFlow SaaS · Módulo: PlanCart
// ─── Tipos del dominio (acordados antes de escribir tests) ─────────────────── type Plan = { id: string; name: string; price: number; // EUR cents }; type Discount = { code: string; type: 'percent' | 'fixed'; value: number; // % o EUR cents expiresAt: Date | null; }; type Money = number; // always EUR cents, never float math type CartError = 'EMPTY_CART'; type DiscountError = 'INVALID_CODE' | 'EXPIRED_CODE'; type Result<T, E> = | { ok: true; value: T } | { ok: false; error: E }; // ─── Interfaz pública ──────────────────────────────────────────────────────── interface IPlanCart { add(plan: Plan): void; applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError>; getTotal(): Money; checkout(): Result<Order, CartError>; }
EXPIRED_CODEEMPTY_CART al intentar checkoutimport { describe, it, expect } from 'vitest'; import { PlanCart } from '../PlanCart'; const starter: Plan = { id: 'starter', name: 'Starter', price: 4900 }; // 49 € describe('PlanCart', () => { it('usuario puede añadir un plan y getTotal devuelve su precio', () => { const cart = new PlanCart(); cart.add(starter); expect(cart.getTotal()).toBe(4900); }); });
export class PlanCart { private plan: Plan | null = null; add(plan: Plan): void { this.plan = plan; } getTotal(): Money { return this.plan?.price ?? 0; } }
const pro: Plan = { id: 'pro', name: 'Pro', price: 9900 }; // 99 € it('cambiar de plan actualiza el total al precio del nuevo plan', () => { const cart = new PlanCart(); cart.add(starter); cart.add(pro); // reemplaza starter expect(cart.getTotal()).toBe(9900); });
const catalog: Discount[] = [ { code: 'CULTIVA20', type: 'percent', value: 20, expiresAt: new Date('2099-12-31'), } ]; it('aplicar cupón porcentual reduce el total en ese porcentaje', () => { const cart = new PlanCart(); cart.add(pro); // 99 € const result = cart.applyDiscount('CULTIVA20', catalog); expect(result.ok).toBe(true); expect(cart.getTotal()).toBe(7920); // 9900 - 20% = 7920 cents });
export class PlanCart { private plan: Plan | null = null; private discount: Discount | null = null; add(plan: Plan): void { this.plan = plan; } applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError> { const d = catalog.find(d => d.code === code); if (!d) return { ok: false, error: 'INVALID_CODE' }; this.discount = d; return { ok: true, value: d }; } getTotal(): Money { if (!this.plan) return 0; if (!this.discount) return this.plan.price; if (this.discount.type === 'percent') { return Math.round(this.plan.price * (1 - this.discount.value / 100)); } return this.plan.price; // fixed: siguiente ciclo } }
const catalogConFijo: Discount[] = [ { code: 'BIENVENIDA10', type: 'fixed', value: 1000, expiresAt: null } ]; it('aplicar cupón fijo resta el importe exacto del total', () => { const cart = new PlanCart(); cart.add(starter); // 49 € cart.applyDiscount('BIENVENIDA10', catalogConFijo); expect(cart.getTotal()).toBe(3900); // 4900 - 1000 cents = 3900 });
getTotal(): Money { if (!this.plan) return 0; if (!this.discount) return this.plan.price; if (this.discount.type === 'percent') { return Math.round(this.plan.price * (1 - this.discount.value / 100)); } return Math.max(0, this.plan.price - this.discount.value); // ← fijo, nunca negativo }
const catalogExpirado: Discount[] = [ { code: 'VERANO24', type: 'percent', value: 15, expiresAt: new Date('2024-09-01'), // expiró en 2024 } ]; it('cupón expirado devuelve error EXPIRED_CODE y no modifica el total', () => { const cart = new PlanCart(); cart.add(pro); const result = cart.applyDiscount('VERANO24', catalogExpirado); expect(result.ok).toBe(false); expect((result as any).error).toBe('EXPIRED_CODE'); expect(cart.getTotal()).toBe(9900); // sin cambios });
applyDiscount(code: string, catalog: Discount[], now = new Date()): Result<Discount, DiscountError> { const d = catalog.find(d => d.code === code); if (!d) return { ok: false, error: 'INVALID_CODE' }; if (d.expiresAt !== null && d.expiresAt < now) { return { ok: false, error: 'EXPIRED_CODE' }; } this.discount = d; return { ok: true, value: d }; }
it('checkout con carrito vacío devuelve error EMPTY_CART', () => { const cart = new PlanCart(); const result = cart.checkout(); expect(result.ok).toBe(false); expect((result as any).error).toBe('EMPTY_CART'); }); it('checkout con plan activo devuelve orden con total correcto', () => { const cart = new PlanCart(); cart.add(starter); const result = cart.checkout(); expect(result.ok).toBe(true); expect((result as any).value.total).toBe(4900); });
checkout(): Result<Order, CartError> { if (!this.plan) return { ok: false, error: 'EMPTY_CART' }; return { ok: true, value: { planId: this.plan.id, planName: this.plan.name, discount: this.discount?.code ?? null, total: this.getTotal(), createdAt: new Date(), } }; }
applyPercentDiscount y applyFixedDiscount como funciones puras privadas → más legible y testeablenow: Date en constructor para facilitar tests con fechas fijas (sin vi.setSystemTime)Order a types.ts compartido con el módulo de invoicingimport type { Plan, Discount, Money, Result, Order, CartError, DiscountError } from './types'; function applyDiscount(price: Money, d: Discount): Money { if (d.type === 'percent') return Math.round(price * (1 - d.value / 100)); return Math.max(0, price - d.value); } function isExpired(d: Discount, now: Date): boolean { return d.expiresAt !== null && d.expiresAt < now; } export class PlanCart { private plan: Plan | null = null; private discount: Discount | null = null; constructor(private readonly now = new Date()) {} add(plan: Plan): void { this.plan = plan; } applyDiscount(code: string, catalog: Discount[]): Result<Discount, DiscountError> { const d = catalog.find(d => d.code === code); if (!d) return { ok: false, error: 'INVALID_CODE' }; if (isExpired(d, this.now)) return { ok: false, error: 'EXPIRED_CODE' }; this.discount = d; return { ok: true, value: d }; } getTotal(): Money { if (!this.plan) return 0; return this.discount ? applyDiscount(this.plan.price, this.discount) : this.plan.price; } checkout(): Result<Order, CartError> { if (!this.plan) return { ok: false, error: 'EMPTY_CART' }; return { ok: true, value: { planId: this.plan.id, planName: this.plan.name, discount: this.discount?.code ?? null, total: this.getTotal(), createdAt: this.now, }}; } }
| # | Comportamiento | Método de test | Resultado | Ciclos RED→GREEN |
|---|---|---|---|---|
| C1 | Añadir plan + getTotal | add() / getTotal() | ✓ PASS | 1 |
| C2 | Cambio de plan reemplaza anterior | add() x2 | ✓ PASS | 0 (ya verde) |
| C3 | Descuento porcentual | applyDiscount() + getTotal() | ✓ PASS | 1 |
| C4 | Descuento fijo | applyDiscount() + getTotal() | ✓ PASS | 1 |
| C5 | Cupón expirado rechazado | applyDiscount() → error | ✓ PASS | 1 |
| C6 | Checkout carrito vacío | checkout() → error | ✓ PASS | 1 |
this.plan, this.discount)PlanCart expone 4 métodos (interfaz pequeña) pero encapsula toda la lógica de descuentos, validación de expiración y construcción de la orden (implementación profunda). Los consumidores del módulo no necesitan conocer cómo se calcula el descuento.