Semana 8 — Repaso y examen parcial

Objetivos

  • Repasar las partes 1 y 2 del curso (semanas 1 a 7) y conectar sus ideas en un mismo caso: del pedido de un cliente a una API y una arquitectura documentadas.
  • Resolver, sin herramientas de IA, problemas del tipo del examen: historias de usuario, especificaciones, diseño de módulos, APIs y ADRs.
  • Revisar críticamente las decisiones de su propio P2 (historias, design doc, API, ADRs) con los criterios de las semanas 3 a 7, y poder defender cada una con sus alternativas.
  • Cerrar el sprint 1 del proyecto con una demo al cliente y la entrega de P2.

Sesión A — Repaso

Lecturas:

  • Las Preguntas de repaso de las guías de las semanas 1, 2, 3, 4, 5, 6 y 7.
  • exams/README.md: formato del examen y cómo estudiar.

Lecturas complementarias:

Temas:

  • Semanas 1 y 2 (1, 2): ingeniería de software como programación en el tiempo, ley de Hyrum, shifting left, trade-offs; qué hace bien y mal la IA y qué dice la evidencia (METR, DORA). Cascada, iterativo, Scrum (roles, eventos, artefactos, Definition of Done) y Kanban (límites de WIP); estimación relativa y por qué una estimación no es un compromiso; las lecciones de proceso del caso Boeing 737 MAX.
  • Semana 3 (3): elicitación y entrevistas (preguntas abiertas, sobre el pasado, sin proponer la solución); historias de usuario con INVEST; criterios de aceptación en Dado/Cuando/Entonces que se puedan verificar; requisitos no funcionales medibles; priorización.
  • Semana 4 (4): especificaciones con precondiciones, postcondiciones e invariantes; los tipos como especificación (uniones de literales, uniones discriminadas, parse, don’t validate) y lo que los tipos no pueden decir; design docs (contexto, objetivos y no objetivos, diseño, alternativas); desarrollo guiado por especificaciones con un agente y cómo se comprueba lo que genera.
  • Semana 5 (5): complejidad (carga cognitiva, cambios que se propagan, incógnitas), módulos profundos y superficiales, ocultamiento de información y abstracciones que se filtran; conceptos (Jackson), acoplamiento y cohesión. Saber señalarlos en un fragmento de TypeScript y proponer una interfaz mejor.
  • Semana 6 (6): diseño de APIs REST (recursos, métodos, códigos de estado, forma de los errores), métodos seguros e idempotentes según RFC 9110 (GET, PUT y DELETE son idempotentes; POST no, salvo con una clave de idempotencia), cambios compatibles e incompatibles, versionado y deprecación; OpenAPI; los patrones que siguen vigentes (strategy, adapter, decorator, observer, repository, dependency injection) y cuándo basta una función.
  • Semana 7 (7): arquitectura como decisiones caras de cambiar; capas, hexagonal, monolito, monolito modular y microservicios; ley de Conway; atributos de calidad como escenarios medibles; ADRs con alternativas; ✂️ los niveles de C4.
  • Ejercicios tipo examen resueltos en clase sobre un caso nuevo (no Kallpa ni los clientes de los equipos): un párrafo de un cliente → historias y criterios; una operación → su especificación; un fragmento de código → sus problemas de diseño; una funcionalidad → su API; una decisión → su ADR.
  • Error común: estudiar memorizando definiciones. El examen da un caso que no han visto y pide aplicar la idea: no basta saber qué es un módulo profundo, hay que reconocer uno superficial en código ajeno y decir cómo cambiar su interfaz.

Sesión B — Examen parcial

Temas: partes 1 y 2 del curso, semanas 1 a 7. Se rinde sin herramientas de IA. Formato y reglas en exams/README.md.

Qué entra:

  • Ingeniería de software y procesos: preguntas cortas sobre la ley de Hyrum, trade-offs, Scrum y Kanban, y sobre qué se puede concluir (y qué no) de un estudio de productividad con IA.
  • Requisitos: a partir de la descripción de un cliente, escribir historias de usuario con criterios de aceptación verificables, y encontrar requisitos ambiguos o contradictorios.
  • Especificaciones: escribir precondiciones, postcondiciones e invariantes de una operación, y los tipos de TypeScript que impiden un estado inválido.
  • Diseño: en un fragmento de TypeScript, identificar módulos superficiales, información que se filtra y acoplamiento, y proponer una interfaz mejor.
  • APIs: diseñar las rutas, los códigos de estado y los errores de una funcionalidad; decir qué operaciones son idempotentes y cómo hacer idempotente una que no lo es.
  • Arquitectura: elegir entre alternativas para un caso dado y escribir el ADR con sus consecuencias; escribir un atributo de calidad como escenario medible.

Cómo estudiar:

  1. Las Preguntas de repaso de las semanas 1 a 7, respondidas por escrito y sin mirar las lecturas.
  2. Su propio P2: expliquen a otra persona del equipo cada decisión del design doc y de los ADRs, y por qué se descartaron las alternativas.
  3. Rehacer en papel los ejercicios del laboratorio: las historias del backlog de Kallpa (semana 3), la refactorización del módulo superficial (semana 5), la especificación OpenAPI (semana 6) y el ADR (semana 7).

Laboratorio — Sin laboratorio

No hay laboratorio esta semana. La demo del sprint 1 se hace con el cliente según el enunciado de P2; el tiempo que queda es para cerrar el sprint (revisión del sprint, retrospectiva con acciones concretas) y planificar el sprint 2 con el enunciado de P3.

Entregas

  • P2 entrega: Diseño y primer sprint: design doc, ADRs, especificación de la API, un esqueleto que funciona de punta a punta y la demo del sprint 1 (projects/p2-diseno/README.md).
  • P3 publicado: Segundo sprint, calidad: funcionalidades con pruebas unitarias y de propiedades, cobertura mínima, revisión de todos los pull requests y análisis estático, con la demo del sprint 2 (projects/p3-calidad/README.md). Entrega en semana 11.

Preguntas de repaso

  1. Un cliente dice: “quiero que la app avise cuando haya un problema con un pedido”. Escriban dos historias de usuario con dos criterios de aceptación cada una, y digan qué le preguntarían al cliente antes de escribirlas.
  2. Escriban la especificación (precondiciones, postcondiciones, errores) de cancel(bookingId) en Kallpa, solo con lo que dice su README, sin mirar el código.
  3. Muestren con un ejemplo de código una abstracción que se filtra y digan cómo cambiarían la interfaz para que deje de filtrarse.
  4. ¿Por qué un POST repetido después de un timeout es un problema y un PUT repetido no? ¿Qué cambiarían en la API de Kallpa para resolverlo?
  5. Para el producto de su equipo, ¿qué decisión de P2 fue de arquitectura? Escriban en cinco líneas su contexto, dos alternativas y sus consecuencias.

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (5)Errores (4)Pruebas (4)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

api.tsDescargar api.ts
// La API del ejercicio: crear un pedido con clave de idempotencia y anularlo.
// node:http y node:sqlite, sin framework, como Kallpa.
import { createServer } from 'node:http';
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { quotePriceCents } from './order.ts';
import type { Service } from './order.ts';

type OrderRequest = { phone: string; weightGrams: number; service: Service };
type Reply = { status: number; body: unknown; location?: string };

// Parse, don't validate: de aquí en adelante, el pedido ya tiene su forma.
function parseOrderRequest(value: unknown): OrderRequest | null {
  if (typeof value !== 'object' || value === null) return null;
  const { phone, weightGrams, service } = value as Record<string, unknown>;
  if (typeof phone !== 'string' || typeof weightGrams !== 'number') return null;
  if (service !== 'normal' && service !== 'express') return null;
  return { phone, weightGrams, service };
}

function failure(status: number, code: string, message: string): Reply {
  return { status, body: { error: { code, message } } };
}

export function openDatabase(): DatabaseSync {
  const db = new DatabaseSync(':memory:');
  db.exec(`
    CREATE TABLE orders (
      id INTEGER PRIMARY KEY,
      phone TEXT NOT NULL,
      weight_grams INTEGER NOT NULL,
      service TEXT NOT NULL,
      price_cents INTEGER NOT NULL,
      status TEXT NOT NULL
    );
    CREATE TABLE idempotency_keys (
      key TEXT PRIMARY KEY,
      request TEXT NOT NULL,
      reply TEXT NOT NULL
    );
  `);
  return db;
}

function createOrder(db: DatabaseSync, raw: string, key: string | undefined): Reply {
  if (key !== undefined) {
    const seen = db.prepare('SELECT request, reply FROM idempotency_keys WHERE key = ?').get(key) as
      | { request: string; reply: string }
      | undefined;
    if (seen) {
      if (seen.request !== raw) {
        return failure(422, 'IDEMPOTENCY_KEY_REUSED', 'Esa clave ya se usó con otro cuerpo.');
      }
      return JSON.parse(seen.reply) as Reply;
    }
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    return failure(400, 'INVALID_JSON', 'El cuerpo no es JSON.');
  }
  const request = parseOrderRequest(parsed);
  if (!request) return failure(400, 'VALIDATION_ERROR', 'Falta un campo o tiene el tipo equivocado.');
  if (!Number.isInteger(request.weightGrams) || request.weightGrams <= 0) {
    return failure(422, 'INVALID_WEIGHT', 'El peso tiene que ser un entero positivo, en gramos.');
  }

  const priceCents = quotePriceCents(request.weightGrams, request.service);
  // El pedido y la clave se guardan en la misma transacción: si se guardara
  // solo el pedido, un reintento lo duplicaría.
  db.exec('BEGIN');
  try {
    const { lastInsertRowid } = db
      .prepare('INSERT INTO orders (phone, weight_grams, service, price_cents, status) VALUES (?, ?, ?, ?, ?)')
      .run(request.phone, request.weightGrams, request.service, priceCents, 'received');
    const id = Number(lastInsertRowid);
    const reply: Reply = {
      status: 201,
      location: `/api/orders/${id}`,
      body: { order: { id, ...request, priceCents, status: 'received' } },
    };
    if (key !== undefined) {
      db.prepare('INSERT INTO idempotency_keys (key, request, reply) VALUES (?, ?, ?)').run(key, raw, JSON.stringify(reply));
    }
    db.exec('COMMIT');
    return reply;
  } catch (error) {
    db.exec('ROLLBACK');
    throw error;
  }
}

function cancelOrder(db: DatabaseSync, id: number): Reply {
  const row = db.prepare('SELECT status FROM orders WHERE id = ?').get(id) as { status: string } | undefined;
  if (!row) return failure(404, 'ORDER_NOT_FOUND', 'El pedido no existe.');
  if (row.status === 'cancelled') return failure(409, 'ALREADY_CANCELLED', 'El pedido ya estaba anulado.');
  if (row.status !== 'received') return failure(409, 'ORDER_IN_PROCESS', 'La ropa ya entró a lavar.');
  db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(id);
  return { status: 200, body: { order: { id, status: 'cancelled' } } };
}

async function readBody(req: IncomingMessage): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  return Buffer.concat(chunks).toString('utf8');
}

function send(res: ServerResponse, reply: Reply): void {
  const headers: Record<string, string> = { 'content-type': 'application/json' };
  if (reply.location) headers.location = reply.location;
  res.writeHead(reply.status, headers);
  res.end(JSON.stringify(reply.body));
}

export function createApi(db: DatabaseSync): Server {
  return createServer(async (req, res) => {
    const raw = await readBody(req);
    const cancel = /^\/api\/orders\/(\d+)\/cancel$/.exec(req.url ?? '');
    if (req.method === 'POST' && req.url === '/api/orders') {
      const key = req.headers['idempotency-key'];
      send(res, createOrder(db, raw, typeof key === 'string' ? key : undefined));
    } else if (req.method === 'POST' && cancel) {
      send(res, cancelOrder(db, Number(cancel[1])));
    } else {
      send(res, failure(404, 'NOT_FOUND', 'La ruta no existe.'));
    }
  });
}
desk.tsDescargar desk.ts
// Una interfaz mejor para el mismo trabajo: quien llama pide lo que necesita y
// no sabe cómo se guardan los pedidos, qué transiciones existen ni cómo se
// calcula el precio.
import { deliver, quotePriceCents } from './order.ts';
import type { DeliverResult, Order, ReadyOrder, Service } from './order.ts';

export type StepResult = { ok: true; order: Order } | { ok: false; code: 'NOT_FOUND' | 'WRONG_STATE' };

// La interfaz del módulo, escrita aparte para mostrarla en el capítulo. La prueba
// comprueba que la clase la cumple.
export type OrderDeskInterface = {
  receive(phone: string, weightGrams: number, service: Service): Order;
  startWashing(id: number): StepResult;
  markReady(id: number): StepResult;
  deliver(id: number, paidCents: number): DeliverResult | { ok: false; code: 'NOT_FOUND' };
  readyFor(phone: string): ReadyOrder[];
  revenueCents(): number;
};

export class OrderDesk {
  #orders = new Map<number, Order>();
  #nextId = 1;
  #now: () => string;

  constructor(now: () => string) {
    this.#now = now;
  }

  receive(phone: string, weightGrams: number, service: Service): Order {
    const order: Order = {
      id: this.#nextId++,
      phone,
      weightGrams,
      service,
      priceCents: quotePriceCents(weightGrams, service),
      receivedAt: this.#now(),
      status: 'received',
    };
    this.#orders.set(order.id, order);
    return structuredClone(order);
  }

  startWashing(id: number): StepResult {
    const order = this.#orders.get(id);
    if (!order) return { ok: false, code: 'NOT_FOUND' };
    if (order.status !== 'received') return { ok: false, code: 'WRONG_STATE' };
    return this.#save({ ...order, status: 'washing' });
  }

  markReady(id: number): StepResult {
    const order = this.#orders.get(id);
    if (!order) return { ok: false, code: 'NOT_FOUND' };
    if (order.status !== 'washing') return { ok: false, code: 'WRONG_STATE' };
    return this.#save({ ...order, status: 'ready', readyAt: this.#now() });
  }

  deliver(id: number, paidCents: number): DeliverResult | { ok: false; code: 'NOT_FOUND' } {
    const order = this.#orders.get(id);
    if (!order) return { ok: false, code: 'NOT_FOUND' };
    const result = deliver(order, paidCents, this.#now());
    if (result.ok) this.#orders.set(id, result.order);
    return structuredClone(result);
  }

  readyFor(phone: string): ReadyOrder[] {
    return [...this.#orders.values()]
      .filter((o): o is ReadyOrder => o.status === 'ready' && o.phone === phone)
      .map((o) => structuredClone(o));
  }

  revenueCents(): number {
    return [...this.#orders.values()]
      .filter((o) => o.status === 'delivered')
      .reduce((sum, o) => sum + o.priceCents, 0);
  }

  #save(order: Order): StepResult {
    this.#orders.set(order.id, order);
    return { ok: true, order: structuredClone(order) };
  }
}
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint. bad/ falla a propósito.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'bad/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
order.tsDescargar order.ts
// Los pedidos de la lavandería: los tipos dicen qué datos existen en cada
// estado, y las funciones dicen qué transiciones se permiten.
export type Service = 'normal' | 'express';

type OrderBase = {
  id: number;
  phone: string;
  weightGrams: number;
  service: Service;
  priceCents: number;
  receivedAt: string;
};

export type ReceivedOrder = OrderBase & { status: 'received' };
export type WashingOrder = OrderBase & { status: 'washing' };
export type ReadyOrder = OrderBase & { status: 'ready'; readyAt: string };
export type DeliveredOrder = OrderBase & { status: 'delivered'; readyAt: string; deliveredAt: string };
export type CancelledOrder = OrderBase & { status: 'cancelled'; cancelledAt: string };

export type Order = ReceivedOrder | WashingOrder | ReadyOrder | DeliveredOrder | CancelledOrder;

export const PRICE_PER_KILO_CENTS = 700;

// Parcial. Precondición: weightGrams es un entero positivo. Postcondición: el
// precio de los kilos completos, redondeando hacia arriba; el exprés cuesta el
// doble. El resultado es un entero, en céntimos.
export function quotePriceCents(weightGrams: number, service: Service): number {
  const kilos = Math.ceil(weightGrams / 1000);
  const base = kilos * PRICE_PER_KILO_CENTS;
  return service === 'express' ? base * 2 : base;
}

// Parcial, con la precondición en el tipo: solo acepta un pedido listo, y tsc
// rechaza cualquier otro. Postcondición: un pedido nuevo, entregado en `now`,
// con los demás datos iguales; `order` no cambia.
export function markDelivered(order: ReadyOrder, now: string): DeliveredOrder {
  return { ...order, status: 'delivered', deliveredAt: now };
}

export type DeliverResult =
  | { ok: true; order: DeliveredOrder }
  | { ok: false; code: 'NOT_READY' | 'ALREADY_DELIVERED' | 'CANCELLED' }
  | { ok: false; code: 'WRONG_AMOUNT'; expectedCents: number };

export function assertNever(value: never): never {
  throw new Error(`Caso no manejado: ${JSON.stringify(value)}`);
}

// Total: para cualquier pedido y cualquier monto dice qué pasó. Esta es la que
// se usa en la frontera, donde el pedido viene de la base de datos y su estado
// no se conoce al compilar. Que el monto sea el precio no lo pueden decir los
// tipos: se revisa al ejecutar.
export function deliver(order: Order, paidCents: number, now: string): DeliverResult {
  switch (order.status) {
    case 'received':
    case 'washing':
      return { ok: false, code: 'NOT_READY' };
    case 'delivered':
      return { ok: false, code: 'ALREADY_DELIVERED' };
    case 'cancelled':
      return { ok: false, code: 'CANCELLED' };
    case 'ready':
      if (paidCents !== order.priceCents) {
        return { ok: false, code: 'WRONG_AMOUNT', expectedCents: order.priceCents };
      }
      return { ok: true, order: markDelivered(order, now) };
    default:
      return assertNever(order);
  }
}
shallow.tsDescargar shallow.ts
// El fragmento del ejercicio de diseño: funciona y tiene varios problemas.
// est: 1 recibido, 2 lavando, 3 listo, 4 entregado.
export type OrderRow = { id: number; cust_phone: string; kg: number; est: number; amount: number };

export class OrderTable {
  #rows: OrderRow[] = [];

  all(): OrderRow[] {
    return this.#rows;
  }

  insert(row: OrderRow): void {
    this.#rows.push(row);
  }

  setEst(id: number, est: number): void {
    const row = this.#rows.find((r) => r.id === id);
    if (row) row.est = est;
  }
}

export class OrderManager {
  #table: OrderTable;

  constructor(table: OrderTable) {
    this.#table = table;
  }

  all(): OrderRow[] {
    return this.#table.all();
  }

  insert(row: OrderRow): void {
    this.#table.insert(row);
  }

  setEst(id: number, est: number): void {
    this.#table.setEst(id, est);
  }
}

// counter.ts: lo que ve quien atiende el mostrador.
export function readyForPickup(manager: OrderManager, phone: string): OrderRow[] {
  return manager.all().filter((r) => r.cust_phone === phone && r.est === 3);
}

// cash.ts: la caja del día.
export function revenue(manager: OrderManager): number {
  return manager.all().filter((r) => r.est === 4).reduce((sum, r) => sum + r.amount, 0);
}

Errores

Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.

bad/deliver-washing.tsDescargar bad/deliver-washing.ts
// No compila: la precondición de markDelivered está en su tipo.
import { markDelivered } from '../order.ts';
import type { WashingOrder } from '../order.ts';

export function finish(order: WashingOrder) {
  return markDelivered(order, '2026-03-18T17:00:00Z');
}
bad/delivered-without-date.tsDescargar bad/delivered-without-date.ts
// No compila: un pedido entregado sin fecha de entrega no se puede construir.
import type { DeliveredOrder } from '../order.ts';

export const order: DeliveredOrder = {
  id: 7,
  phone: '999111222',
  weightGrams: 3200,
  service: 'normal',
  priceCents: 2800,
  receivedAt: '2026-03-16T10:00:00Z',
  readyAt: '2026-03-18T09:00:00Z',
  status: 'delivered',
};
bad/missing-case.tsDescargar bad/missing-case.ts
// No compila: falta el caso 'cancelled', y assertNever recibe un valor que
// todavía puede existir.
import { assertNever } from '../order.ts';
import type { Order } from '../order.ts';

export function label(order: Order): string {
  switch (order.status) {
    case 'received':
      return 'Recibido';
    case 'washing':
      return 'Lavando';
    case 'ready':
      return 'Listo para recoger';
    case 'delivered':
      return 'Entregado';
    default:
      return assertNever(order);
  }
}
bad/tsconfig.jsonDescargar bad/tsconfig.json
{
  "extends": "../tsconfig.json",
  "include": ["*.ts"],
  "exclude": []
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

api.test.tsDescargar api.test.ts
// El ejercicio de APIs del capítulo, contra un servidor de verdad en un puerto
// libre: un POST repetido duplica, salvo que lleve una clave de idempotencia.
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { after, before, test } from 'node:test';
import type { Server } from 'node:http';
import type { DatabaseSync } from 'node:sqlite';
import { createApi, openDatabase } from './api.ts';

let db: DatabaseSync;
let server: Server;
let base: string;

before(async () => {
  db = openDatabase();
  server = createApi(db);
  await new Promise<void>((resolve) => server.listen(0, resolve));
  base = `http://localhost:${(server.address() as AddressInfo).port}`;
});

after(() => {
  server.close();
});

function count(): number {
  const { n } = db.prepare('SELECT count(*) AS n FROM orders').get() as { n: number };
  return n;
}

function post(body: unknown, key?: string): Promise<Response> {
  const headers: Record<string, string> = { 'content-type': 'application/json' };
  if (key) headers['idempotency-key'] = key;
  return fetch(`${base}/api/orders`, { method: 'POST', headers, body: JSON.stringify(body) });
}

const shirts = { phone: '999111222', weightGrams: 3200, service: 'normal' };

test('sin clave, repetir el POST crea dos pedidos', async () => {
  const before = count();
  const first = await post(shirts);
  const second = await post(shirts);
  assert.equal(first.status, 201);
  assert.equal(second.status, 201);
  assert.equal(count(), before + 2);
});

test('con clave, el reintento devuelve la misma respuesta y crea un solo pedido', async () => {
  const before = count();
  const first = await post(shirts, 'a1f3');
  const second = await post(shirts, 'a1f3');
  assert.equal(first.status, 201);
  assert.equal(second.status, 201);
  assert.equal(second.headers.get('location'), first.headers.get('location'));
  assert.deepEqual(await second.json(), await first.json());
  assert.equal(count(), before + 1);
});

test('la misma clave con otro cuerpo se rechaza con 422', async () => {
  await post(shirts, 'b2c4');
  const before = count();
  const other = await post({ ...shirts, weightGrams: 5000 }, 'b2c4');
  assert.equal(other.status, 422);
  const body = (await other.json()) as { error: { code: string } };
  assert.equal(body.error.code, 'IDEMPOTENCY_KEY_REUSED');
  assert.equal(count(), before);
});

test('el cuerpo mal formado da 400 y la regla incumplida da 422', async () => {
  const noWeight = await post({ phone: '999', service: 'normal' });
  assert.equal(noWeight.status, 400);
  const negative = await post({ ...shirts, weightGrams: -1 });
  assert.equal(negative.status, 422);
});

test('anular: el efecto de repetirlo es el mismo, aunque la respuesta cambie', async () => {
  const created = (await (await post(shirts)).json()) as { order: { id: number } };
  const url = `${base}/api/orders/${created.order.id}/cancel`;
  const status = () => db.prepare('SELECT status FROM orders WHERE id = ?').get(created.order.id);
  const first = await fetch(url, { method: 'POST' });
  assert.equal(first.status, 200);
  const afterFirst = status();
  const second = await fetch(url, { method: 'POST' });
  assert.equal(second.status, 409);
  assert.deepEqual(status(), afterFirst);
});

test('un pedido que no existe da 404', async () => {
  const res = await fetch(`${base}/api/orders/99999/cancel`, { method: 'POST' });
  assert.equal(res.status, 404);
});
design.test.tsDescargar design.test.ts
// El ejercicio de diseño del capítulo. Las primeras pruebas muestran que los
// problemas de shallow.ts son reales; las últimas, que OrderDesk los resuelve.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { OrderDesk } from './desk.ts';
import type { OrderDeskInterface } from './desk.ts';
import { OrderManager, OrderTable, readyForPickup, revenue } from './shallow.ts';

function manager(): OrderManager {
  const m = new OrderManager(new OrderTable());
  m.insert({ id: 1, cust_phone: '999', kg: 3, est: 3, amount: 2100 });
  m.insert({ id: 2, cust_phone: '999', kg: 2, est: 4, amount: 1400 });
  return m;
}

test('shallow: all() entrega la lista interna y se puede vaciar desde afuera', () => {
  const m = manager();
  m.all().length = 0;
  assert.equal(revenue(m), 0);
  assert.deepEqual(readyForPickup(m, '999'), []);
});

test('shallow: setEst acepta un código que no existe y saltos de estado', () => {
  const m = manager();
  m.setEst(1, 99);
  assert.equal(m.all()[0]?.est, 99);
  m.setEst(2, 1);
  assert.equal(revenue(m), 0, 'un pedido entregado "volvió" a recibido y la caja bajó');
});

test('shallow: un estado nuevo no le avisa a nadie', () => {
  const m = manager();
  // 5 = anulado: un código que nadie declaró. Compila, y los que leen est
  // simplemente dejan de ver el pedido, sin que nada les pregunte si está bien.
  m.setEst(1, 5);
  assert.deepEqual(readyForPickup(m, '999'), []);
});

function desk(): OrderDeskInterface {
  return new OrderDesk(() => '2026-03-18T17:00:00Z');
}

test('desk: el camino completo, de recibido a entregado', () => {
  const d = desk();
  const order = d.receive('999', 3200, 'normal');
  assert.equal(order.priceCents, 2800);
  assert.equal(d.startWashing(order.id).ok, true);
  assert.equal(d.markReady(order.id).ok, true);
  assert.deepEqual(d.readyFor('999').map((o) => o.id), [order.id]);
  assert.equal(d.deliver(order.id, 2800).ok, true);
  assert.equal(d.revenueCents(), 2800);
  assert.deepEqual(d.readyFor('999'), []);
});

test('desk: no se puede saltar un estado', () => {
  const d = desk();
  const order = d.receive('999', 1000, 'normal');
  assert.deepEqual(d.markReady(order.id), { ok: false, code: 'WRONG_STATE' });
  assert.deepEqual(d.deliver(order.id, 700), { ok: false, code: 'NOT_READY' });
  assert.deepEqual(d.startWashing(999), { ok: false, code: 'NOT_FOUND' });
});

test('desk: lo que devuelve es una copia, no el estado interno', () => {
  const d = desk();
  const order = d.receive('999', 1000, 'normal');
  order.priceCents = 1;
  d.startWashing(order.id);
  d.markReady(order.id);
  const [ready] = d.readyFor('999');
  assert.equal(ready?.priceCents, 700);
  if (ready) ready.phone = 'otro';
  assert.equal(d.readyFor('999').length, 1);
});
order.test.tsDescargar order.test.ts
// El ejercicio de especificación del capítulo: cada prueba es una cláusula de
// la especificación de deliver y de quotePriceCents.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { deliver, quotePriceCents } from './order.ts';
import type { Order, ReadyOrder } from './order.ts';

const NOW = '2026-03-18T17:00:00Z';

const ready: ReadyOrder = {
  id: 7,
  phone: '999111222',
  weightGrams: 3200,
  service: 'normal',
  priceCents: 2800,
  receivedAt: '2026-03-16T10:00:00Z',
  status: 'ready',
  readyAt: '2026-03-18T09:00:00Z',
};

test('el precio redondea al kilo superior y el exprés cuesta el doble', () => {
  assert.equal(quotePriceCents(3200, 'normal'), 2800);
  assert.equal(quotePriceCents(3000, 'normal'), 2100);
  assert.equal(quotePriceCents(3200, 'express'), 5600);
});

test('entregar un pedido listo con el monto exacto cumple la postcondición', () => {
  const result = deliver(ready, 2800, NOW);
  assert.deepEqual(result, { ok: true, order: { ...ready, status: 'delivered', deliveredAt: NOW } });
});

test('entregar no cambia el pedido original', () => {
  const before = structuredClone(ready);
  deliver(ready, 2800, NOW);
  assert.deepEqual(ready, before);
});

test('un monto distinto del precio se rechaza y dice cuánto era', () => {
  assert.deepEqual(deliver(ready, 2000, NOW), { ok: false, code: 'WRONG_AMOUNT', expectedCents: 2800 });
});

test('deliver es total: cada estado tiene una respuesta definida', () => {
  const { readyAt, ...base } = ready;
  const cases: [Order, string][] = [
    [{ ...base, status: 'received' }, 'NOT_READY'],
    [{ ...base, status: 'washing' }, 'NOT_READY'],
    [{ ...base, status: 'delivered', readyAt, deliveredAt: NOW }, 'ALREADY_DELIVERED'],
    [{ ...base, status: 'cancelled', cancelledAt: NOW }, 'CANCELLED'],
  ];
  for (const [order, code] of cases) {
    assert.deepEqual(deliver(order, 2800, NOW), { ok: false, code });
  }
});
typecheck.test.tsDescargar typecheck.test.ts
// Lo que el capítulo dice de tsc, comprobado con tsc: los archivos de bad/
// fallan donde el capítulo dice.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';

test('cada archivo de bad/ falla con el error que muestra el capítulo', () => {
  const result = spawnSync('tsc', ['-p', 'bad', '--pretty', 'false'], { encoding: 'utf8' });
  assert.notEqual(result.status, 0);
  const errors = result.stdout
    .split('\n')
    .filter((line) => line.startsWith('bad/'))
    .map((line) => line.replace(/^(bad\/[\w-]+\.ts)\((\d+),\d+\): error (TS\d+):.*$/, '$1:$2 $3'));
  assert.deepEqual(errors, [
    'bad/deliver-washing.ts:6 TS2345',
    'bad/delivered-without-date.ts:4 TS2322',
    'bad/missing-case.ts:17 TS2345',
  ]);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 8: repaso para el examen parcial. Los ejercicios resueltos del
# capítulo son de una lavandería, un caso nuevo; no usan Kallpa. Se usa dentro
# del contenedor del curso.
#
#   make           no hay nada que preparar esta semana
#   make test      revisa los tipos (tsc), corre ESLint y las pruebas
#                  (*.test.ts), que comprueban lo que dice el capítulo
#   make errores   muestra los errores de tsc de bad/, que fallan a propósito
#   make clean     borra build/

.PHONY: all test errores clean

all:
	@echo "Nada que preparar: prueben con make test."

test:
	tsc -p .
	eslint .
	node --test "*.test.ts"

errores:
	-tsc -p bad --pretty false

clean:
	rm -rf build
package.jsonDescargar package.json
{
  "name": "week-08",
  "private": true,
  "type": "module"
}
tsconfig.jsonDescargar tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "lib": ["es2024"],
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["*.ts"],
  "exclude": ["build", "bad"]
}