Semana 6 — APIs y patrones de diseño

Objetivos

  • Diseñar una API HTTP orientada a recursos: rutas, métodos, códigos de estado y una forma de error estable.
  • Explicar qué significa que un método sea seguro o idempotente según RFC 9110, y usar una clave de idempotencia para que un reintento no duplique una operación.
  • Distinguir los cambios compatibles de los que rompen a quien llama a la API, y planificar una versión nueva y una deprecación.
  • Reconocer en código real los patrones de diseño que siguen vigentes (strategy, adapter, decorator, observer, repository, dependency injection) y cuándo una función basta en lugar de un patrón.
  • Documentar una API existente con OpenAPI y revisarla con Redocly.

Sesión A — Interfaces y APIs: REST, errores, versionado e idempotencia

Lecturas:

Lecturas complementarias:

Temas:

  • Una API es un contrato con quien la llama, que no está en la sala cuando la cambian. Por la ley de Hyrum, todo lo observable (el orden de una lista, el texto de un mensaje, un campo que sobra) termina siendo parte del contrato. Por eso se diseña con cuidado y se cambia con reglas.
  • Diseño orientado a recursos: la API expone sustantivos (canchas, reservas, miembros) organizados en colecciones (/api/courts, /api/courts/1), y pocos verbos estándar. Según Google: List y Get con GET, Create con POST, Update con PATCH y Delete con DELETE. Una acción que no encaja, como cancelar una reserva, es un método propio: POST /api/bookings/12:cancel en el estilo de Google, o POST /api/bookings/12/cancel. Error común: verbos en las rutas para operaciones estándar (/api/getCourts) o un GET que cambia datos. Los proxies y los navegadores repiten un GET sin preguntar.
  • Seguro e idempotente (RFC 9110). Un método es seguro si quien llama no pide ningún cambio de estado: GET, HEAD, OPTIONS, TRACE. Es idempotente si el efecto sobre el servidor de N solicitudes iguales es el mismo que el de una: los seguros, más PUT y DELETE. La respuesta puede cambiar: un segundo DELETE puede dar 404, pero el estado del servidor es el mismo. POST y PATCH no son idempotentes por definición.
  • Por qué importa: las redes fallan. Si una reserva tarda y el celular corta la conexión, la app no sabe si la reserva se hizo. Un reintento de un método idempotente es seguro; uno de POST puede duplicar la operación, o fallar sin decir que la primera vez funcionó. La solución habitual es una clave de idempotencia: quien llama manda una cabecera Idempotency-Key con un valor único; el servidor guarda la clave con el resultado y, ante un reintento con la misma clave, responde de forma consistente sin repetir la operación. El borrador de la IETF recomienda 422 si se reusa una clave con otro cuerpo y 409 si la primera solicitud todavía se está procesando.
  • Códigos de estado con significado: 200 OK; 201 Created con la cabecera Location de lo creado; 204 No Content; 400 Bad Request (mal formado); 401 (no se sabe quién es) frente a 403 (se sabe y no puede); 404; 405 con la cabecera Allow; 409 Conflict (choca con el estado actual); 413 Content Too Large; 422 Unprocessable Content (bien formado, pero no cumple una regla); 429; 500; 503. La frontera entre 400 y 422 es una decisión de diseño: escríbanla y respétenla en todos los endpoints.
  • Errores: un código estable que un programa pueda comparar, un mensaje para personas y, si sirve, detalles por campo. RFC 9457 define un formato estándar (application/problem+json con type, title, status, detail); Google y Microsoft usan { "error": { "code", "message", ... } }, como Kallpa. Importa menos cuál se elija que usar el mismo en toda la API.
  • Evolución y versiones. Un cambio compatible no rompe a nadie: agregar un endpoint, un campo opcional en la solicitud o un campo en la respuesta. Uno incompatible sí: quitar o renombrar un campo, cambiar su tipo o su significado, volver obligatorio algo opcional, cambiar un código de estado. Devolver un arreglo suelto en lugar de un objeto ([...] frente a { "bookings": [...] }) deja sin lugar para agregar metadatos de paginación (nextPageToken): pasar al objeto rompe a quien espera el arreglo. Aun con el objeto, AIP-158 considera incompatible agregar paginación después, porque quien llama solo vería la primera página: conviene paginar desde el inicio. Error común: renombrar un campo “porque se ve mejor” en una API que ya tiene clientes HTTP.
  • Deprecación: la versión vieja y la nueva conviven un tiempo (/v1/ y /v2/, un parámetro como ?api-version= o una cabecera de versión). La vieja se anuncia con las cabeceras Deprecation y Sunset (la fecha en que deja de existir), se mide quién la sigue usando y recién entonces se apaga. Se retoma en la semana 16.

Sesión B — Patrones de diseño que siguen vigentes

Lecturas:

Lecturas complementarias:

  • Peter Norvig, Design Patterns in Dynamic Languages (1996): 16 de los 23 patrones del libro de la “pandilla de los cuatro” se vuelven invisibles o más simples, al menos en algunos usos, en lenguajes como Dylan o Lisp; las funciones de primera clase explican una parte.
  • Martin Fowler, Repository, del catálogo de Patterns of Enterprise Application Architecture.

Temas:

  • Un patrón de diseño es una solución con nombre a un problema que se repite, con sus trade-offs. El catálogo clásico es de 1994 (Gamma, Helm, Johnson y Vlissides), escrito para C++ y Smalltalk. Lo que sigue vigente es el problema que cada patrón resuelve y el vocabulario: decir “aquí va un adapter” ahorra un párrafo en una revisión de código.
  • Strategy: cambiar un algoritmo sin tocar a quien lo usa. En TypeScript suele ser solo una función o un objeto con una función. En Kallpa, Clock es una estrategia para obtener “ahora”: systemClock en producción, fixedClock en las pruebas.
  • Dependency injection: un módulo recibe sus dependencias en lugar de crearlas. createServer({ db, clock, notifier }) recibe la base de datos, el reloj y el notificador, y por eso las pruebas pueden usar :memory:, un reloj fijo y un notificador que guarda los mensajes. No hace falta un contenedor de DI: pasar parámetros al construir el objeto es DI.
  • Adapter: hacer que una interfaz existente encaje con la que el código espera. Notifier es la interfaz que Kallpa espera. Cuando el complejo contrate WhatsApp Business, el adapter traduce bookingConfirmed(...) a la API de ese proveedor, y la lógica de reservas no cambia. Repository: una colección de objetos del dominio que esconde cómo se guardan (src/store/).
  • Decorator y middleware: envolver una función con otra que agrega algo antes o después sin cambiarla. En src/http/server.ts, cada handler corre dentro de un try/catch/finally que convierte los errores en respuestas JSON y escribe una línea de log. Sacar eso a funciones withErrors(handler) o withLog(handler) es el patrón decorator.
  • Observer: quien produce un evento no conoce a quienes reaccionan. “Se confirmó una reserva” podría notificar por WhatsApp, actualizar la caja y registrar una métrica, sin que la reserva sepa de ninguno. En Node existe EventEmitter. El costo es que el flujo ya no se lee de corrido en el código.
  • Error común: aplicar patrones porque existen. Una BookingFactoryProvider para crear un objeto que se crea con un literal agrega interfaces sin esconder nada: es un módulo superficial (semana 5). Un Singleton es estado global con otro nombre y complica las pruebas. La pregunta es siempre la misma: ¿qué cambio futuro abarata este patrón y qué tan probable es?
  • Con IA. Los modelos de lenguaje reproducen los patrones que más aparecen en el código con el que se entrenaron, a veces con más ceremonia de la necesaria (interfaces con una sola implementación, fábricas para todo). Al revisar código generado, pregunten qué cambio facilita cada abstracción. Una especificación OpenAPI también sirve con IA: es un contrato contra el que se puede comprobar un cliente HTTP generado, en lugar de confiar en que los campos que usa existen.

Laboratorio — Diseñar y documentar una API con OpenAPI

Guía: este documento. Referencias: OpenAPI Specification y Learn OpenAPI; Redocly CLI, lint y build-docs.

La especificación describe la API de Kallpa como es hoy, no como debería ser; las mejoras se proponen aparte. En parejas, sobre su copia de Kallpa, con dos terminales en el mismo contenedor (./env/run.sh en cada una).

  1. En una terminal, make seed y make start. En la otra, recorrer la API con curl -s -i y anotar en una tabla, por endpoint, el código de estado y la forma del cuerpo de cada respuesta, también las de error. Para reservar, usen una fecha dentro de los 14 días y una hora libre:
    D=$(date -d '+8 days' +%F)
    BODY="{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"teamName\":\"Los Halcones\"}"
    curl -s -i -X POST localhost:3000/api/bookings -H 'content-type: application/json' -d "$BODY"
  2. Repetir exactamente la misma reserva, como si fuera un reintento después de un timeout. Cancelarla dos veces (ID=…; curl -s -i -X POST localhost:3000/api/bookings/$ID/cancel). Anotar qué ve quien llama en cada caso y si puede saber qué pasó la primera vez.
  3. Copiar el esqueleto code/week-06/openapi.yaml a docs/openapi.yaml. Ya trae info, el esquema Error y GET /api/courts completo, y declara security: []: Kallpa no tiene autenticación y la especificación lo dice en voz alta (se retoma en la semana 15). Correr redocly lint docs/openapi.yaml.
  4. Describir todos los endpoints de su copia (siete en la Kallpa publicada; más, si ya integraron su issue de P0): parámetros de ruta y de consulta, el cuerpo de POST /api/bookings (campos obligatorios, minimum, maximum y maxLength según las reglas del README) y todas las respuestas, con sus códigos de error en un enum y el esquema Error con $ref. Usar components/schemas para Booking, Court y Member.
  5. Llevar redocly lint a cero errores. Cada advertencia que quede se corrige o se justifica con un comentario en el YAML (por ejemplo, el servidor localhost). Generar la documentación con redocly build-docs docs/openapi.yaml -o docs/api.html y abrirla en el navegador de su computadora.
  6. Revisión de diseño en docs/api-revision.md: comparar su tabla y su especificación con las guías de Google y de Microsoft. Encuentren al menos cuatro inconsistencias (en códigos de estado, en la forma de las respuestas, en nombres de campos, en la frontera entre 400 y 422, en lo que pasa al reintentar). Para cada una: ¿corregirla rompe a quien ya llama a la API? ¿Cómo la harían convivir con la versión actual?
  7. Diseñar en el mismo documento una clave de idempotencia para POST /api/bookings: qué se guarda, por cuánto tiempo, qué se responde ante un reintento con la misma clave y ante la misma clave con otro cuerpo. Opcional: implementarla, con una prueba nueva que haga el reintento.

Entregas

  • No hay entregas esta semana. P2 (diseño y primer sprint) pide una especificación de la API como la de hoy (projects/p2-diseno/); se entrega en la semana 8.

Preguntas de repaso

  1. ¿Es idempotente POST /api/bookings/12/cancel? ¿Y PATCH /api/bookings/12 con { "status": "cancelled" }? Justifiquen con la definición de RFC 9110, no con el método.
  2. Una app pierde la conexión justo después de enviar una reserva. Describan qué pasa al reintentar con y sin una clave de idempotencia.
  3. Clasifiquen como compatible o incompatible: agregar price a la respuesta de la disponibilidad; cambiar hour por startHour; pasar un error de 400 a 422; agregar un parámetro opcional ?sport=.
  4. ¿Qué patrón de diseño es fixedClock en las pruebas de Kallpa? ¿Qué se perdería si cada función llamara a new Date()?
  5. Un compañero propone una interfaz NotifierFactory con una sola implementación que devuelve consoleNotifier. ¿Qué le responderían en la revisión de código?

Código de la semana

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

Ejemplos (20)Pruebas (7)Compilación (3)

Ejemplos

Los programas que usan los apuntes y la clase.

charges-server.tsDescargar charges-server.ts
// Un servidor de pagos mínimo: POST /charges no tiene una regla que frene un
// duplicado, como sí la tiene una cancha ocupada. Sirve para ver el reintento
// que cobra dos veces y lo que cambia con una clave de idempotencia.
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { withIdempotency, type IdempotencyEntry, type OperationResponse } from './idempotency.ts';

export type Charge = { id: number; memberId: number; cents: number };

export type ChargesServer = { url: string; charges: Charge[]; close(): Promise<void> };

const DAY_MS = 24 * 60 * 60 * 1000;

export async function startChargesServer(options: { requireKey: boolean }): Promise<ChargesServer> {
  const charges: Charge[] = [];
  const operation = async (request: Omit<Charge, 'id'>): Promise<OperationResponse> => {
    const charge = { id: charges.length + 1, ...request };
    charges.push(charge);
    return { status: 201, body: { charge } };
  };
  const store = new Map<string, IdempotencyEntry>();
  const idempotent = withIdempotency(store, operation, { now: Date.now, ttlMs: DAY_MS });

  const server = http.createServer(async (req, res) => {
    const chunks: Buffer[] = [];
    for await (const chunk of req) chunks.push(chunk as Buffer);
    const request = JSON.parse(Buffer.concat(chunks).toString('utf8')) as Omit<Charge, 'id'>;
    const key = req.headers['idempotency-key'];
    const result = options.requireKey
      ? await idempotent(typeof key === 'string' ? key : undefined, request)
      : await operation(request);
    res.writeHead(result.status, { 'content-type': 'application/json' });
    res.end(JSON.stringify(result.body));
  });
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}`,
    charges,
    close: () =>
      new Promise<void>((resolve, reject) => {
        server.closeAllConnections();
        server.close((error) => (error ? reject(error) : resolve()));
      }),
  };
}
client-example.tsDescargar client-example.ts
// Ilustración escrita para la clase: un cliente HTTP como los que suele
// proponer un agente cuando no tiene a la vista la especificación. Compila,
// corre y no lanza ningún error, pero dos de sus campos no existen.
export const FIELDS_USED = ['courts[].courtName', 'courts[].sportType'];

export async function courtLabels(baseUrl: string): Promise<string[]> {
  const response = await fetch(`${baseUrl}/api/courts`);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const data: any = await response.json();
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return data.courts.map((court: any) => `${court.courtName} (${court.sportType})`);
}
contract.tsDescargar contract.ts
// Un validador mínimo de JSON Schema, solo con lo que usa la especificación de
// Kallpa, para comparar respuestas reales con openapi.yaml ya empaquetado como
// JSON (make: redocly bundle). No reemplaza a una biblioteca de validación.
export type Schema = {
  $ref?: string;
  type?: string | string[];
  required?: string[];
  properties?: Record<string, Schema>;
  items?: Schema;
  enum?: unknown[];
  minimum?: number;
  maximum?: number;
  maxLength?: number;
  minLength?: number;
};

export type OpenApi = {
  paths: Record<string, Record<string, Operation>>;
  components?: { schemas?: Record<string, Schema>; responses?: Record<string, ResponseSpec> };
};
type ResponseSpec = { $ref?: string; content?: Record<string, { schema: Schema }> };
type Operation = { responses: Record<string, ResponseSpec> };

function resolve(spec: OpenApi, schema: Schema): Schema {
  if (schema.$ref === undefined) return schema;
  const name = schema.$ref.replace('#/components/schemas/', '');
  const target = spec.components?.schemas?.[name];
  if (!target) throw new Error(`$ref sin destino: ${schema.$ref}`);
  return target;
}

function typeOf(value: unknown): string {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  if (Number.isInteger(value)) return 'integer';
  return typeof value;
}

// Devuelve la lista de discrepancias: vacía si el valor cumple el esquema.
export function validate(spec: OpenApi, value: unknown, rawSchema: Schema, path = '$'): string[] {
  const schema = resolve(spec, rawSchema);
  const problems: string[] = [];
  if (schema.type !== undefined) {
    const allowed = Array.isArray(schema.type) ? schema.type : [schema.type];
    const actual = typeOf(value);
    if (!allowed.includes(actual) && !(actual === 'integer' && allowed.includes('number'))) {
      return [`${path}: se esperaba ${allowed.join(' o ')} y llegó ${actual}`];
    }
  }
  if (schema.enum && !schema.enum.includes(value)) problems.push(`${path}: ${JSON.stringify(value)} no está en el enum`);
  if (typeof value === 'number') {
    if (schema.minimum !== undefined && value < schema.minimum) problems.push(`${path}: menor que ${schema.minimum}`);
    if (schema.maximum !== undefined && value > schema.maximum) problems.push(`${path}: mayor que ${schema.maximum}`);
  }
  if (typeof value === 'string') {
    if (schema.maxLength !== undefined && value.length > schema.maxLength) problems.push(`${path}: más largo que ${schema.maxLength}`);
    if (schema.minLength !== undefined && value.length < schema.minLength) problems.push(`${path}: más corto que ${schema.minLength}`);
  }
  if (Array.isArray(value) && schema.items) {
    value.forEach((item, i) => problems.push(...validate(spec, item, schema.items!, `${path}[${i}]`)));
  }
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
    const fields = value as Record<string, unknown>;
    for (const name of schema.required ?? []) {
      if (!(name in fields)) problems.push(`${path}.${name}: falta`);
    }
    for (const [name, property] of Object.entries(schema.properties ?? {})) {
      if (name in fields) problems.push(...validate(spec, fields[name], property, `${path}.${name}`));
    }
  }
  return problems;
}

// El esquema de una respuesta: responseSchema(spec, '/api/courts', 'get', '200').
export function responseSchema(spec: OpenApi, path: string, method: string, status: string): Schema {
  const declared = spec.paths[path]?.[method]?.responses?.[status];
  if (!declared) throw new Error(`la especificación no declara ${method} ${path} ${status}`);
  const response = declared.$ref
    ? spec.components?.responses?.[declared.$ref.replace('#/components/responses/', '')]
    : declared;
  const schema = response?.content?.['application/json']?.schema;
  if (!schema) throw new Error(`${method} ${path} ${status} no declara un cuerpo JSON`);
  return schema;
}

// ¿Existe este campo en el esquema? 'courts[].name' recorre objetos y arreglos.
export function fieldExists(spec: OpenApi, rawSchema: Schema, fieldPath: string): boolean {
  let schema = resolve(spec, rawSchema);
  for (const part of fieldPath.split('.')) {
    const isArray = part.endsWith('[]');
    const name = isArray ? part.slice(0, -2) : part;
    const next = schema.properties?.[name];
    if (!next) return false;
    schema = resolve(spec, next);
    if (isArray) {
      if (!schema.items) return false;
      schema = resolve(spec, schema.items);
    }
  }
  return true;
}
decorator-server.diffDescargar decorator-server.diff
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..09ac4b5 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -1,4 +1,4 @@
-import http from 'node:http';
+import http, { type IncomingMessage, type ServerResponse } from 'node:http';
 import type { DatabaseSync } from 'node:sqlite';
 import { systemClock, type Clock } from '../domain/clock.ts';
 import { consoleNotifier, type Notifier } from '../notifications/notifier.ts';
@@ -36,27 +36,13 @@ export function buildRouter(deps: AppDependencies): Router {
   return router;
 }
 
-export function createServer(options: ServerOptions): http.Server {
-  const deps: AppDependencies = {
-    service: new BookingService(options.db),
-    clock: options.clock ?? systemClock,
-    notifier: options.notifier ?? consoleNotifier,
-  };
-  const log = options.log ?? console.log;
-  const router = buildRouter(deps);
+type RawHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
 
-  return http.createServer(async (req, res) => {
-    const url = new URL(req.url ?? '/', 'http://localhost');
+// Convierte cualquier error en una respuesta JSON.
+function withErrors(next: RawHandler): RawHandler {
+  return async (req, res) => {
     try {
-      if (req.method === 'GET' && (await serveStatic(url.pathname, res))) return;
-      const match = router.match(req.method ?? 'GET', url.pathname);
-      if (match.kind === 'not-found') {
-        throw new HttpError(404, 'NOT_FOUND', `No existe la ruta ${url.pathname}.`);
-      }
-      if (match.kind === 'method-not-allowed') {
-        throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} no está permitido en ${url.pathname}.`);
-      }
-      await match.handler({ req, res, url, params: match.params });
+      await next(req, res);
     } catch (error) {
       if (error instanceof HttpError) {
         sendError(res, error);
@@ -64,8 +50,45 @@ export function createServer(options: ServerOptions): http.Server {
         console.error(error);
         sendError(res, new HttpError(500, 'INTERNAL_ERROR', 'Ocurrió un error inesperado.'));
       }
+    }
+  };
+}
+
+// Escribe una línea de log al terminar, pase lo que pase. Va por fuera de
+// withErrors para ver el código de estado que de verdad se respondió.
+function withLog(log: (line: string) => void, next: RawHandler): RawHandler {
+  return async (req, res) => {
+    try {
+      await next(req, res);
     } finally {
       log(`${req.method} ${req.url} ${res.statusCode}`);
     }
-  });
+  };
+}
+
+function dispatcher(router: Router): RawHandler {
+  return async (req, res) => {
+    const url = new URL(req.url ?? '/', 'http://localhost');
+    if (req.method === 'GET' && (await serveStatic(url.pathname, res))) return;
+    const match = router.match(req.method ?? 'GET', url.pathname);
+    if (match.kind === 'not-found') {
+      throw new HttpError(404, 'NOT_FOUND', `No existe la ruta ${url.pathname}.`);
+    }
+    if (match.kind === 'method-not-allowed') {
+      throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} no está permitido en ${url.pathname}.`);
+    }
+    await match.handler({ req, res, url, params: match.params });
+  };
+}
+
+export function createServer(options: ServerOptions): http.Server {
+  const deps: AppDependencies = {
+    service: new BookingService(options.db),
+    clock: options.clock ?? systemClock,
+    notifier: options.notifier ?? consoleNotifier,
+  };
+  const log = options.log ?? console.log;
+  const router = buildRouter(deps);
+
+  return http.createServer(withLog(log, withErrors(dispatcher(router))));
 }
eslint.config.jsDescargar eslint.config.js
// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';

export default defineConfig(
  { ignores: ['build/', 'solution/'] },
  js.configs.recommended,
  tseslint.configs.recommended,
);
evolution.tsDescargar evolution.ts
// Tres clientes HTTP de la misma respuesta de disponibilidad, y los cambios de
// la API que cada uno aguanta. Los cambios se simulan sobre la respuesta real.
export type Slot = { hour: number; available: boolean; teamName?: string };
export type Availability = { courtId: number; date: string; slots: Slot[] };

// Lee solo lo que necesita e ignora lo demás.
export function freeHoursTolerant(body: Availability): number[] {
  return body.slots.filter((slot) => slot.available).map((slot) => slot.hour);
}

// Exige exactamente los campos que vio el día que lo escribieron.
export function freeHoursStrict(body: Availability): number[] {
  for (const slot of body.slots) {
    const allowed = ['hour', 'available', 'teamName'];
    const unknown = Object.keys(slot).filter((key) => !allowed.includes(key));
    if (unknown.length > 0) throw new Error(`campo desconocido: ${unknown.join(', ')}`);
  }
  return freeHoursTolerant(body);
}

// Lee el arreglo suelto de GET /api/members/:id/bookings.
export function countConfirmed(body: { status: string }[]): number {
  return body.filter((booking) => booking.status === 'confirmed').length;
}

// Decide qué mostrar según el código de estado.
export function errorKind(status: number): 'revise-el-formulario' | 'no-se-puede' | 'desconocido' {
  if (status === 400) return 'revise-el-formulario';
  if (status === 409) return 'no-se-puede';
  return 'desconocido';
}

// Los cambios que clasifica el capítulo.
export function addPriceField(body: Availability): unknown {
  return { ...body, slots: body.slots.map((slot) => ({ ...slot, priceCents: 9000 })) };
}

export function renameHourField(body: Availability): unknown {
  return {
    ...body,
    slots: body.slots.map(({ hour, ...rest }) => ({ startHour: hour, ...rest })),
  };
}
idempotency.tsDescargar idempotency.ts
// Una clave de idempotencia como decorator de una operación: la operación no
// sabe nada de claves. La memoria es un Map para mostrar la lógica; en un
// sistema real la clave se guarda en la misma transacción que el efecto.
import { createHash } from 'node:crypto';

export type OperationResponse = { status: number; body: unknown };

export type IdempotencyEntry =
  | { state: 'in-progress'; fingerprint: string; storedAt: number }
  | { state: 'done'; fingerprint: string; storedAt: number; response: OperationResponse };

export type IdempotencyOptions = {
  now: () => number; // milisegundos; se inyecta para probar el vencimiento
  ttlMs: number;
};

export type Replayable = OperationResponse & { replayed: boolean };

// El mismo contenido da la misma huella, en cualquier orden de campos.
export function fingerprint(request: unknown): string {
  return createHash('sha256').update(canonicalJson(request)).digest('hex');
}

export function canonicalJson(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
  if (typeof value === 'object' && value !== null) {
    const fields = Object.entries(value)
      .sort(([a], [b]) => (a < b ? -1 : 1))
      .map(([key, field]) => `${JSON.stringify(key)}:${canonicalJson(field)}`);
    return `{${fields.join(',')}}`;
  }
  return JSON.stringify(value);
}

function errorResponse(status: number, code: string, message: string): Replayable {
  return { status, body: { error: { code, message } }, replayed: false };
}

export function withIdempotency<Request>(
  store: Map<string, IdempotencyEntry>,
  operation: (request: Request) => Promise<OperationResponse>,
  options: IdempotencyOptions,
) {
  return async (key: string | undefined, request: Request): Promise<Replayable> => {
    if (key === undefined) {
      return errorResponse(400, 'IDEMPOTENCY_KEY_MISSING', 'Esta operación pide la cabecera Idempotency-Key.');
    }
    const print = fingerprint(request);
    const now = options.now();
    const existing = store.get(key);
    if (existing && now - existing.storedAt < options.ttlMs) {
      if (existing.fingerprint !== print) {
        return errorResponse(422, 'IDEMPOTENCY_KEY_REUSED', 'Esa clave ya se usó con otro cuerpo.');
      }
      if (existing.state === 'in-progress') {
        return errorResponse(409, 'REQUEST_IN_PROGRESS', 'La primera solicitud con esa clave todavía se procesa.');
      }
      return { ...existing.response, replayed: true };
    }
    // Se marca antes del primer await: un reintento concurrente ya ve la clave.
    store.set(key, { state: 'in-progress', fingerprint: print, storedAt: now });
    try {
      const response = await operation(request);
      store.set(key, { state: 'done', fingerprint: print, storedAt: now, response });
      return { ...response, replayed: false };
    } catch (error) {
      // No se sabe qué pasó: se libera la clave para que un reintento pueda
      // volver a correr la operación.
      store.delete(key);
      throw error;
    }
  };
}
lost-response.tsDescargar lost-response.ts
// Una red que falla en el peor momento: el servidor recibe la solicitud y la
// procesa, pero quien llama nunca ve la respuesta porque la conexión se corta.
import http from 'node:http';
import type { AddressInfo } from 'node:net';

export type FlakyProxy = { url: string; close(): Promise<void> };

// Reenvía todo al servidor real. Las primeras `drops` respuestas no llegan.
export async function startFlakyProxy(target: string, drops = 1): Promise<FlakyProxy> {
  let remaining = drops;
  const server = http.createServer(async (req, res) => {
    const chunks: Buffer[] = [];
    for await (const chunk of req) chunks.push(chunk as Buffer);
    const headers = new Headers();
    for (const [name, value] of Object.entries(req.headers)) {
      if (typeof value === 'string' && name !== 'host' && name !== 'connection') headers.set(name, value);
    }
    const upstream = await fetch(`${target}${req.url}`, {
      method: req.method,
      headers,
      body: chunks.length > 0 ? Buffer.concat(chunks) : undefined,
    });
    const text = await upstream.text();
    if (remaining > 0) {
      remaining--;
      req.socket.destroy(); // el servidor ya hizo su trabajo; la respuesta se pierde
      return;
    }
    res.writeHead(upstream.status, { 'content-type': 'application/json' });
    res.end(text);
  });
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}`,
    close: () =>
      new Promise<void>((resolve, reject) => {
        server.closeAllConnections();
        server.close((error) => (error ? reject(error) : resolve()));
      }),
  };
}

export type Attempt =
  | { kind: 'response'; status: number; body: unknown }
  | { kind: 'connection-lost'; message: string };

// Lo que hace una app al reintentar: POST, y si se corta, otra vez lo mismo.
export async function postWithRetry(
  url: string,
  body: unknown,
  headers: Record<string, string> = {},
  maxAttempts = 2,
): Promise<Attempt[]> {
  const attempts: Attempt[] = [];
  for (let i = 0; i < maxAttempts; i++) {
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'content-type': 'application/json', ...headers },
        body: JSON.stringify(body),
      });
      attempts.push({ kind: 'response', status: response.status, body: await response.json() });
      return attempts;
    } catch (error) {
      attempts.push({ kind: 'connection-lost', message: (error as Error).message });
    }
  }
  return attempts;
}
openapi.yamlDescargar openapi.yaml
openapi: 3.1.0
info:
  title: Kallpa API
  version: 1.0.0
  description: Reservas de canchas.
security: []
servers:
  - url: http://localhost:3000
paths:
  /api/courts:
    get:
      operationId: listCourts
      summary: Lista las canchas
      responses:
        '200':
          description: Las canchas
          content:
            application/json:
              schema:
                type: object
                required: [courts]
                properties:
                  courts:
                    type: array
                    items:
                      $ref: '#/components/schemas/Court'
components:
  schemas:
    Court:
      type: object
      required: [id, name, sport]
      properties:
        id: { type: integer }
        name: { type: string }
        sport: { type: string, enum: [fulbito, voley, basquet] }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
recorrido-v2.shDescargar recorrido-v2.sh
#!/usr/bin/env bash
# Las dos versiones conviviendo, en la copia de Kallpa que deja make v2.
set -euo pipefail

export PORT="${PORT:-3101}"
cd build/v2
rm -f data/kallpa.db data/kallpa.db-*
make --no-print-directory seed > /dev/null
node src/main.ts > /tmp/kallpa-v2.log 2>&1 &
SERVER=$!
trap 'kill "$SERVER"' EXIT
sleep 1

for path in members/5/bookings v2/members/5/bookings; do
  echo "\$ curl -s -i localhost:$PORT/api/$path"
  curl -s -i "localhost:$PORT/api/$path" | tr -d '\r' \
    | grep -v -i -E '^(date|connection|keep-alive|content-length|content-type):' \
    | grep -v '^$' | cut -c1-90
  echo
done
tail -2 /tmp/kallpa-v2.log
recorrido.shDescargar recorrido.sh
#!/usr/bin/env bash
# Reserva, reintenta y cancela dos veces en una copia de Kallpa, como el paso 2
# del laboratorio. Las fechas dependen del día en que se corre.
set -euo pipefail

export PORT="${PORT:-3100}"
cd build/kallpa
rm -f data/kallpa.db data/kallpa.db-*
make --no-print-directory seed
node src/main.ts > /tmp/kallpa-recorrido.log 2>&1 &
SERVER=$!
trap 'kill "$SERVER"' EXIT
sleep 1

API="localhost:$PORT"
D=$(date -d '+8 days' +%F)
BODY="{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"teamName\":\"Los Halcones\"}"
JSON='content-type: application/json'

# Muestra la línea de estado, las cabeceras que importan aquí y el cuerpo.
show() {
  echo "\$ curl $*"
  curl -s -i "$@" | tr -d '\r' \
    | grep -v -i -E '^(date|connection|keep-alive|content-length|content-type):' \
    | grep -v '^$' | cut -c1-200
  echo
}

echo "--- reservar y reintentar"
show -X POST "$API/api/bookings" -H "$JSON" -d "$BODY"
show -X POST "$API/api/bookings" -H "$JSON" -d "$BODY"

ID=$(curl -s "$API/api/members/2/bookings" | jq 'map(select(.teamName == "Los Halcones"))[0].id')

echo "--- cancelar dos veces"
show -X POST "$API/api/bookings/$ID/cancel"
show -X POST "$API/api/bookings/$ID/cancel"

echo "--- dos errores: la frase de estado la pone Node"
show -X POST "$API/api/bookings" -H "$JSON" -d "{\"courtId\":2,\"memberId\":2,\"date\":\"$D\",\"startHour\":10,\"hours\":4,\"teamName\":\"X\"}"
node -e 'console.log(JSON.stringify({ teamName: "x".repeat(17000) }))' > /tmp/kallpa-grande.json
show -X POST "$API/api/bookings" -H "$JSON" --data @/tmp/kallpa-grande.json
retry-demo.tsDescargar retry-demo.ts
// make retry: lo que ve quien llama cuando la primera respuesta se pierde.
import { startTestServer } from './build/kallpa/test/helpers.ts';
import { startChargesServer } from './charges-server.ts';
import { postWithRetry, startFlakyProxy, type Attempt } from './lost-response.ts';

function print(title: string, attempts: Attempt[]): void {
  console.log(title);
  attempts.forEach((attempt, i) => {
    const seen =
      attempt.kind === 'response'
        ? `${attempt.status} ${JSON.stringify(attempt.body)}`
        : `se cortó la conexión (${attempt.message})`;
    console.log(`  intento ${i + 1}: ${seen}`);
  });
}

const kallpa = await startTestServer();
const proxyK = await startFlakyProxy(kallpa.url);
const booking = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
print('Kallpa, POST /api/bookings', await postWithRetry(`${proxyK.url}/api/bookings`, booking));
const list = await (await fetch(`${kallpa.url}/api/members/2/bookings`)).json();
console.log(`  reservas del vecino 2 en la base de datos: ${list.length}\n`);
await proxyK.close();
await kallpa.close();

const plain = await startChargesServer({ requireKey: false });
const proxyP = await startFlakyProxy(plain.url);
print('Pagos sin clave, POST /charges', await postWithRetry(`${proxyP.url}/charges`, { memberId: 2, cents: 8100 }));
console.log(`  cobros en la base de datos: ${plain.charges.length}\n`);
await proxyP.close();
await plain.close();

const keyed = await startChargesServer({ requireKey: true });
const proxyI = await startFlakyProxy(keyed.url);
const key = { 'idempotency-key': '"8e03978e-40d5-43e8-bc93-6894a57f9324"' };
print('Pagos con clave, POST /charges', await postWithRetry(`${proxyI.url}/charges`, { memberId: 2, cents: 8100 }, key));
console.log(`  cobros en la base de datos: ${keyed.charges.length}`);
await proxyI.close();
await keyed.close();
v2-bookings.diffDescargar v2-bookings.diff
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..ec3043e 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -143,12 +143,30 @@ export function cancelBooking(deps: AppDependencies): Handler {
   };
 }
 
-// GET /api/members/:id/bookings
+// La v1 sigue respondiendo igual: solo se anuncia su fin (RFC 9745 y RFC 8594).
+export const MEMBER_BOOKINGS_V1_DEPRECATED_AT = 1798761600; // 1 de enero de 2027, 00:00 UTC
+export const MEMBER_BOOKINGS_V1_SUNSET = 'Sat, 01 Jan 2028 00:00:00 GMT';
+
+function bookingsOfMember(deps: AppDependencies, id: string): Booking[] {
+  const member = deps.service.findMember(Number(id));
+  if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${id}.`);
+  return deps.service.bookingsForMember(member.id).map(toBookingJson);
+}
+
+// GET /api/members/:id/bookings: arreglo suelto, deprecado.
 export function memberBookings(deps: AppDependencies): Handler {
   return ({ res, params }) => {
-    const member = deps.service.findMember(Number(params.id));
-    if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${params.id}.`);
-    sendJson(res, 200, deps.service.bookingsForMember(member.id).map(toBookingJson));
+    res.setHeader('Deprecation', `@${MEMBER_BOOKINGS_V1_DEPRECATED_AT}`);
+    res.setHeader('Sunset', MEMBER_BOOKINGS_V1_SUNSET);
+    res.setHeader('Link', `</api/v2/members/${params.id}/bookings>; rel="successor-version"`);
+    sendJson(res, 200, bookingsOfMember(deps, params.id));
+  };
+}
+
+// GET /api/v2/members/:id/bookings: un objeto, que puede crecer.
+export function memberBookingsV2(deps: AppDependencies): Handler {
+  return ({ res, params }) => {
+    sendJson(res, 200, { bookings: bookingsOfMember(deps, params.id) });
   };
 }
 
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..5820ff3 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -11,6 +11,7 @@ import {
   listCourts,
   listMembers,
   memberBookings,
+  memberBookingsV2,
   type AppDependencies,
 } from './handlers.ts';
 import { HttpError, sendError } from './json.ts';
@@ -30,6 +31,7 @@ export function buildRouter(deps: AppDependencies): Router {
   router.get('/api/courts/:id/availability', courtAvailability(deps));
   router.get('/api/members', listMembers(deps));
   router.get('/api/members/:id/bookings', memberBookings(deps));
+  router.get('/api/v2/members/:id/bookings', memberBookingsV2(deps));
   router.post('/api/bookings', createBooking(deps));
   router.post('/api/bookings/:id/cancel', cancelBooking(deps));
   router.get('/api/reports/daily', dailyReport(deps));
diff --git a/test/v2.test.ts b/test/v2.test.ts
new file mode 100644
index 0000000..e4f764a
--- /dev/null
+++ b/test/v2.test.ts
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, test } from 'node:test';
+import { postJson, startTestServer, type TestServer } from './helpers.ts';
+
+// El reloj de las pruebas marca el lunes 9 de marzo de 2026 a las 10:00.
+describe('las reservas de un vecino, en v1 y en v2', () => {
+  let server: TestServer;
+
+  before(async () => {
+    server = await startTestServer();
+    await postJson(`${server.url}/api/bookings`, { courtId: 1, memberId: 2, date: '2026-03-11', startHour: 10, teamName: 'Real Barranco' });
+  });
+
+  after(async () => {
+    await server.close();
+  });
+
+  test('v1 responde lo mismo que antes y anuncia su fin', async () => {
+    const response = await fetch(`${server.url}/api/members/2/bookings`);
+    const body = await response.json();
+    assert.equal(response.status, 200);
+    assert.ok(Array.isArray(body));
+    assert.equal(response.headers.get('deprecation'), '@1798761600');
+    assert.equal(response.headers.get('sunset'), 'Sat, 01 Jan 2028 00:00:00 GMT');
+    assert.equal(response.headers.get('link'), '</api/v2/members/2/bookings>; rel="successor-version"');
+  });
+
+  test('el fin no es anterior a la deprecación', async () => {
+    const response = await fetch(`${server.url}/api/members/2/bookings`);
+    const deprecation = Number(response.headers.get('deprecation')!.slice(1)) * 1000;
+    assert.ok(Date.parse(response.headers.get('sunset')!) >= deprecation);
+  });
+
+  test('v2 devuelve las mismas reservas dentro de un objeto y sin aviso', async () => {
+    const v1 = await (await fetch(`${server.url}/api/members/2/bookings`)).json();
+    const response = await fetch(`${server.url}/api/v2/members/2/bookings`);
+    assert.deepEqual(await response.json(), { bookings: v1 });
+    assert.equal(response.headers.get('deprecation'), null);
+  });
+
+  test('los errores no cambian entre versiones', async () => {
+    const response = await fetch(`${server.url}/api/v2/members/99/bookings`);
+    assert.equal(response.status, 404);
+    assert.equal(((await response.json()) as { error: { code: string } }).error.code, 'MEMBER_NOT_FOUND');
+  });
+});
lint-examples/broken-ref.yamlDescargar lint-examples/broken-ref.yaml
# Un error que Redocly sí encuentra: el $ref apunta a un esquema que no existe.
openapi: 3.1.0
info:
  title: Kallpa API
  version: 1.0.0
security: []
servers:
  - url: http://localhost:3000
paths:
  /api/courts:
    get:
      operationId: listCourts
      summary: Lista las canchas
      responses:
        '200':
          description: Las canchas
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Courts'
components:
  schemas:
    Court:
      type: object
      properties:
        id: { type: integer }
lint-examples/wrong-but-valid.yamlDescargar lint-examples/wrong-but-valid.yaml
# Un error que Redocly no puede encontrar: el documento es válido, pero Kallpa
# responde 200 con { booking }, no 201 con { id }. Lint compara la especificación
# con las reglas de OpenAPI, no con el servidor.
openapi: 3.1.0
info:
  title: Kallpa API
  version: 1.0.0
security: []
servers:
  - url: http://localhost:3000
paths:
  /api/bookings:
    post:
      operationId: createBooking
      summary: Reserva una cancha
      responses:
        '201':
          description: La reserva creada
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id: { type: integer }
        '409':
          description: La cancha está ocupada
patterns/adapter.tsDescargar patterns/adapter.ts
// Adapter: el proveedor tiene su interfaz y Kallpa espera otra. El proveedor de
// este archivo es inventado para la clase: la forma de sus mensajes no copia la
// API de ninguna empresa.
import type { BookingConfirmation, Notifier } from '../build/kallpa/src/notifications/notifier.ts';

export type TemplateMessage = {
  to: string; // con código de país: +51987654321
  template: string;
  params: string[];
};

export type MessagingProvider = {
  sendTemplate(message: TemplateMessage): void;
};

// Lo único que sabe de los dos lados: traduce una llamada en la otra.
export function messagingNotifier(provider: MessagingProvider): Notifier {
  return {
    bookingConfirmed(c: BookingConfirmation) {
      provider.sendTemplate({
        to: `+51${c.phone}`,
        template: 'reserva_confirmada',
        params: [c.memberName, c.courtName, c.date, `${c.startHour}:00`, String(c.hours), String(c.priceCents)],
      });
    },
  };
}
patterns/decorator.tsDescargar patterns/decorator.ts
// Decorator: una función envuelve a otra de la misma forma y agrega algo antes
// o después, sin que la de adentro se entere. Es lo que hace decorator-server.diff
// con el try/catch/finally de src/http/server.ts.
import type { IncomingMessage, ServerResponse } from 'node:http';
import { HttpError, sendError } from '../build/kallpa/src/http/json.ts';

export type RawHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;

export function withErrors(next: RawHandler): RawHandler {
  return async (req, res) => {
    try {
      await next(req, res);
    } catch (error) {
      if (error instanceof HttpError) {
        sendError(res, error);
      } else {
        sendError(res, new HttpError(500, 'INTERNAL_ERROR', 'Ocurrió un error inesperado.'));
      }
    }
  };
}

export function withLog(log: (line: string) => void, next: RawHandler): RawHandler {
  return async (req, res) => {
    try {
      await next(req, res);
    } finally {
      log(`${req.method} ${req.url} ${res.statusCode}`);
    }
  };
}
patterns/observer.tsDescargar patterns/observer.ts
// Observer con EventEmitter: la reserva avisa que se confirmó y no sabe quién
// escucha. Los eventos están tipados para que tsc revise el nombre y el dato.
import { EventEmitter } from 'node:events';

export type BookingConfirmedEvent = { bookingId: number; courtId: number; priceCents: number };

type BookingEvents = {
  'booking-confirmed': [BookingConfirmedEvent];
};

export function createBookingEvents(): EventEmitter<BookingEvents> {
  return new EventEmitter<BookingEvents>();
}

// Quien produce el evento: guarda y emite. No importa a nadie de los que reaccionan.
export function confirmBooking(
  events: EventEmitter<BookingEvents>,
  saved: BookingConfirmedEvent[],
  booking: BookingConfirmedEvent,
): void {
  saved.push(booking);
  events.emit('booking-confirmed', booking);
}
patterns/overkill.tsDescargar patterns/overkill.ts
// Lo que un patrón no debería hacer: dos ejemplos escritos para discutir en una
// revisión de código.
import { consoleNotifier, type Notifier } from '../build/kallpa/src/notifications/notifier.ts';
import type { Clock } from '../build/kallpa/src/domain/clock.ts';

// Una fábrica con una sola implementación. Ilustración de la ceremonia que a
// veces proponen un agente o un programador con el libro recién leído.
export interface NotifierFactory {
  create(): Notifier;
}

export class ConsoleNotifierFactory implements NotifierFactory {
  create(): Notifier {
    return consoleNotifier;
  }
}

export class NotifierFactoryProvider {
  getFactory(): NotifierFactory {
    return new ConsoleNotifierFactory();
  }
}

// Una factory function que sí hace algo: elige la implementación según un dato.
export function notifierFor(channel: 'console' | 'silent'): Notifier {
  return channel === 'console' ? consoleNotifier : { bookingConfirmed() {} };
}

// Un Singleton: estado global con otro nombre. Quien lo cambia afecta a todos.
export class GlobalClock implements Clock {
  static #instance: GlobalClock | undefined;
  #fixed: Date | undefined;

  static getInstance(): GlobalClock {
    GlobalClock.#instance ??= new GlobalClock();
    return GlobalClock.#instance;
  }

  fix(instant: string): void {
    this.#fixed = new Date(instant);
  }

  now(): Date {
    return this.#fixed ?? new Date();
  }
}

// El código que depende del Singleton no lo dice en su firma.
export function isMorningWithSingleton(): boolean {
  return GlobalClock.getInstance().now().getUTCHours() < 17; // 12:00 en Lima
}

// El mismo código con el reloj como parámetro.
export function isMorning(clock: Clock): boolean {
  return clock.now().getUTCHours() < 17;
}
patterns/strategy.tsDescargar patterns/strategy.ts
// Strategy: el algoritmo que ordena las horas libres se cambia sin tocar a
// quien lo usa. Primero como lo escribiría el libro, con una interfaz y clases;
// después como función, que en TypeScript hace lo mismo.
export type Slot = { hour: number; available: boolean };

// Con interfaz y clases.
export interface HourRanker {
  compare(a: number, b: number): number;
}

export class EarliestFirst implements HourRanker {
  compare(a: number, b: number): number {
    return a - b;
  }
}

export class ClosestTo implements HourRanker {
  #target: number;

  constructor(target: number) {
    this.#target = target;
  }

  compare(a: number, b: number): number {
    return Math.abs(a - this.#target) - Math.abs(b - this.#target) || a - b;
  }
}

export function suggestWithRanker(slots: Slot[], ranker: HourRanker): number[] {
  return slots
    .filter((slot) => slot.available)
    .map((slot) => slot.hour)
    .sort((a, b) => ranker.compare(a, b));
}

// Con una función: el tipo de la estrategia es el de un comparador.
export type Rank = (a: number, b: number) => number;

export const earliestFirst: Rank = (a, b) => a - b;
export const closestTo =
  (target: number): Rank =>
  (a, b) =>
    Math.abs(a - target) - Math.abs(b - target) || a - b;

export function suggest(slots: Slot[], rank: Rank): number[] {
  return slots
    .filter((slot) => slot.available)
    .map((slot) => slot.hour)
    .sort(rank);
}

Pruebas

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

api.test.tsDescargar api.test.ts
// Lo que el apunte dice de la API publicada de Kallpa, comprobado contra un
// servidor de prueba: base de datos en memoria y reloj fijo (lunes 9 de marzo
// de 2026, 10:00 en Lima).
import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';

const request = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };

async function call(url: string, method = 'GET', body?: unknown) {
  const response = await fetch(url, {
    method,
    headers: body === undefined ? {} : { 'content-type': 'application/json' },
    body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
  });
  return { status: response.status, body: await response.json() };
}

describe('la API de Kallpa como es hoy', () => {
  let server: TestServer;

  before(async () => {
    server = await startTestServer();
  });

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

  test('la primera reserva se confirma', async () => {
    const { status, body } = await call(`${server.url}/api/bookings`, 'POST', request);
    assert.equal(status, 200);
    assert.equal(body.booking.status, 'confirmed');
  });

  test('un reintento de la misma reserva responde 409 SLOT_TAKEN, sin decir de quién es', async () => {
    const { status, body } = await call(`${server.url}/api/bookings`, 'POST', request);
    assert.equal(status, 409);
    assert.equal(body.error.code, 'SLOT_TAKEN');
    assert.equal(JSON.stringify(body).includes('booking"'), false);
  });

  test('el reintento no crea una segunda reserva', async () => {
    const { body } = await call(`${server.url}/api/members/2/bookings`);
    assert.equal(body.length, 1);
    assert.equal(server.sent.length, 1);
  });

  test('cancelar dos veces: el estado queda igual y la respuesta cambia', async () => {
    const list = await call(`${server.url}/api/members/2/bookings`);
    const id = list.body[0].id;
    const first = await call(`${server.url}/api/bookings/${id}/cancel`, 'POST');
    assert.equal(first.status, 200);
    assert.equal(first.body.booking.status, 'cancelled');
    const second = await call(`${server.url}/api/bookings/${id}/cancel`, 'POST');
    assert.equal(second.status, 409);
    assert.equal(second.body.error.code, 'ALREADY_CANCELLED');
    const after = await call(`${server.url}/api/members/2/bookings`);
    assert.deepEqual(after.body[0], first.body.booking);
  });

  test('reservar no es idempotente: si la reserva se canceló en el medio, el reintento crea otra', async () => {
    const date = '2026-03-18';
    const slot = { ...request, date };
    const first = await call(`${server.url}/api/bookings`, 'POST', slot);
    await call(`${server.url}/api/bookings/${first.body.booking.id}/cancel`, 'POST');
    const retry = await call(`${server.url}/api/bookings`, 'POST', slot);
    assert.equal(retry.status, 200);
    assert.notEqual(retry.body.booking.id, first.body.booking.id);
    const list = await call(`${server.url}/api/members/2/bookings`);
    const sameSlot = list.body.filter((b: { date: string }) => b.date === date);
    assert.equal(sameSlot.length, 2);
  });

  test('las listas tienen dos formas: objeto en canchas y miembros, arreglo suelto en las reservas de un vecino', async () => {
    assert.ok(Array.isArray((await call(`${server.url}/api/courts`)).body.courts));
    assert.ok(Array.isArray((await call(`${server.url}/api/members`)).body.members));
    assert.ok(Array.isArray((await call(`${server.url}/api/members/2/bookings`)).body));
  });

  test('todos los errores tienen la misma forma: error.code y error.message', async () => {
    const responses = [
      await call(`${server.url}/api/getCourts`),
      await call(`${server.url}/api/courts`, 'DELETE'),
      await call(`${server.url}/api/bookings`, 'POST', '{mal'),
      await call(`${server.url}/api/bookings/9999/cancel`, 'POST'),
      await call(`${server.url}/api/members/99/bookings`),
      await call(`${server.url}/api/reports/daily`),
    ];
    for (const { body } of responses) {
      assert.deepEqual(Object.keys(body), ['error']);
      assert.deepEqual(Object.keys(body.error), ['code', 'message']);
    }
    assert.deepEqual(
      responses.map((r) => r.status),
      [404, 405, 400, 404, 404, 400],
    );
  });

  test('un cuerpo de más de 16 KiB responde 413 BODY_TOO_LARGE', async () => {
    const big = JSON.stringify({ teamName: 'x'.repeat(17 * 1024) });
    const { status, body } = await call(`${server.url}/api/bookings`, 'POST', big);
    assert.equal(status, 413);
    assert.equal(body.error.code, 'BODY_TOO_LARGE');
  });
});
contract.test.tsDescargar contract.test.ts
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';
import { courtLabels, FIELDS_USED } from './client-example.ts';
import { fieldExists, responseSchema, validate, type OpenApi } from './contract.ts';

const spec = JSON.parse(readFileSync('build/openapi.json', 'utf8')) as OpenApi;
const courts = responseSchema(spec, '/api/courts', 'get', '200');

describe('el esqueleto openapi.yaml contra Kallpa', () => {
  let server: TestServer;

  before(async () => {
    server = await startTestServer();
  });

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

  test('GET /api/courts responde lo que dice la especificación', async () => {
    const body = await (await fetch(`${server.url}/api/courts`)).json();
    assert.deepEqual(validate(spec, body, courts), []);
  });

  test('el validador encuentra un campo que falta y un valor fuera del enum', () => {
    const broken = { courts: [{ id: 1, name: 'Fulbito 1', sport: 'tenis' }, { id: 2, sport: 'voley' }] };
    assert.deepEqual(validate(spec, broken, courts), [
      '$.courts[0].sport: "tenis" no está en el enum',
      '$.courts[1].name: falta',
    ]);
  });

  test('el cliente de ejemplo corre sin errores y muestra basura', async () => {
    const labels = await courtLabels(server.url);
    assert.equal(labels.length, 4);
    assert.ok(labels.every((label) => label === 'undefined (undefined)'));
  });

  test('la especificación detecta los campos inventados sin correr el cliente', () => {
    assert.deepEqual(
      FIELDS_USED.filter((field) => !fieldExists(spec, courts, field)),
      ['courts[].courtName', 'courts[].sportType'],
    );
    assert.equal(fieldExists(spec, courts, 'courts[].name'), true);
    assert.equal(fieldExists(spec, courts, 'courts[].sport'), true);
  });

  test('el esqueleto declara security: [] y un esquema Error con code y message', () => {
    const raw = spec as unknown as { security: unknown[]; components: { schemas: Record<string, unknown> } };
    assert.deepEqual(raw.security, []);
    assert.ok(raw.components.schemas.Error);
  });
});
evolution.test.tsDescargar evolution.test.ts
import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import { startTestServer, type TestServer } from './build/kallpa/test/helpers.ts';
import {
  addPriceField,
  countConfirmed,
  errorKind,
  freeHoursStrict,
  freeHoursTolerant,
  renameHourField,
  type Availability,
} from './evolution.ts';

describe('qué rompe cada cambio', () => {
  let server: TestServer;
  let body: Availability;

  before(async () => {
    server = await startTestServer();
    body = (await (await fetch(`${server.url}/api/courts/1/availability?date=2026-03-17`)).json()) as Availability;
  });

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

  test('agregar un campo en la respuesta: el cliente tolerante sigue igual', () => {
    const changed = addPriceField(body) as Availability;
    assert.deepEqual(freeHoursTolerant(changed), freeHoursTolerant(body));
  });

  test('agregar un campo en la respuesta: el cliente que exige campos exactos se cae', () => {
    assert.throws(() => freeHoursStrict(addPriceField(body) as Availability), /campo desconocido: priceCents/);
  });

  test('renombrar hour a startHour: el cliente tolerante no se cae, pero devuelve basura', () => {
    const hours = freeHoursTolerant(renameHourField(body) as Availability);
    assert.equal(hours.length, 15);
    assert.ok(hours.every((hour) => hour === undefined));
  });

  test('envolver el arreglo suelto en un objeto: el cliente se cae', () => {
    const wrapped = { bookings: [{ status: 'confirmed' }] };
    assert.equal(countConfirmed(wrapped.bookings), 1);
    assert.throws(() => countConfirmed(wrapped as unknown as { status: string }[]), TypeError);
  });

  test('pasar un error de 400 a 422: el cliente que comparaba el código ya no lo reconoce', () => {
    assert.equal(errorKind(400), 'revise-el-formulario');
    assert.equal(errorKind(422), 'desconocido');
  });
});
idempotency.test.tsDescargar idempotency.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { canonicalJson, fingerprint, withIdempotency, type IdempotencyEntry } from './idempotency.ts';

type Charge = { memberId: number; cents: number };
const charge: Charge = { memberId: 2, cents: 8100 };
const TTL = 24 * 60 * 60 * 1000;

function setup() {
  const charges: Charge[] = [];
  let now = 0;
  const store = new Map<string, IdempotencyEntry>();
  const operation = async (request: Charge) => {
    charges.push(request);
    return { status: 201, body: { id: charges.length, ...request } };
  };
  const send = withIdempotency(store, operation, { now: () => now, ttlMs: TTL });
  return { charges, store, send, advance: (ms: number) => (now += ms) };
}

test('sin clave no se ejecuta nada: 400', async () => {
  const { charges, send } = setup();
  const response = await send(undefined, charge);
  assert.equal(response.status, 400);
  assert.equal(charges.length, 0);
});

test('un reintento con la misma clave y el mismo cuerpo devuelve la misma respuesta sin repetir el efecto', async () => {
  const { charges, send } = setup();
  const first = await send('k1', charge);
  const retry = await send('k1', charge);
  assert.equal(charges.length, 1);
  assert.equal(first.replayed, false);
  assert.equal(retry.replayed, true);
  assert.equal(retry.status, first.status);
  assert.deepEqual(retry.body, first.body);
});

test('el orden de los campos no cambia la huella', async () => {
  assert.equal(fingerprint({ a: 1, b: { c: 2, d: 3 } }), fingerprint({ b: { d: 3, c: 2 }, a: 1 }));
  assert.equal(canonicalJson({ b: [1, { z: 1, y: 2 }], a: null }), '{"a":null,"b":[1,{"y":2,"z":1}]}');
  const { charges, send } = setup();
  await send('k1', { memberId: 2, cents: 8100 });
  const retry = await send('k1', { cents: 8100, memberId: 2 });
  assert.equal(retry.replayed, true);
  assert.equal(charges.length, 1);
});

test('la misma clave con otro cuerpo: 422 y sin efecto', async () => {
  const { charges, send } = setup();
  await send('k1', charge);
  const other = await send('k1', { ...charge, cents: 9000 });
  assert.equal(other.status, 422);
  assert.equal(charges.length, 1);
});

test('un reintento mientras la primera todavía corre: 409', async () => {
  const store = new Map<string, IdempotencyEntry>();
  let finish: (value: { status: number; body: unknown }) => void = () => {};
  const slow = () => new Promise<{ status: number; body: unknown }>((resolve) => (finish = resolve));
  const send = withIdempotency(store, slow, { now: () => 0, ttlMs: TTL });
  const first = send('k1', charge);
  const concurrent = await send('k1', charge);
  assert.equal(concurrent.status, 409);
  finish({ status: 201, body: { id: 1 } });
  assert.equal((await first).status, 201);
  assert.equal((await send('k1', charge)).replayed, true);
});

test('cuando vence la clave, el mismo pedido vuelve a ejecutarse', async () => {
  const { charges, send, advance } = setup();
  await send('k1', charge);
  advance(TTL - 1);
  assert.equal((await send('k1', charge)).replayed, true);
  advance(1);
  assert.equal((await send('k1', charge)).replayed, false);
  assert.equal(charges.length, 2);
});

test('las respuestas de error de la operación también se guardan y se repiten', async () => {
  const store = new Map<string, IdempotencyEntry>();
  let calls = 0;
  const rejected = async () => {
    calls++;
    return { status: 409, body: { error: { code: 'SLOT_TAKEN' } } };
  };
  const send = withIdempotency(store, rejected, { now: () => 0, ttlMs: TTL });
  await send('k1', charge);
  const retry = await send('k1', charge);
  assert.equal(calls, 1);
  assert.equal(retry.status, 409);
  assert.equal(retry.replayed, true);
});

test('el límite de este diseño: si el proceso falla después del efecto, el reintento lo repite', async () => {
  const store = new Map<string, IdempotencyEntry>();
  const charges: Charge[] = [];
  let fail = true;
  const operation = async (request: Charge) => {
    charges.push(request); // el efecto ya ocurrió...
    if (fail) throw new Error('se cayó antes de guardar la respuesta'); // ...y la respuesta se pierde
    return { status: 201, body: { id: charges.length } };
  };
  const send = withIdempotency(store, operation, { now: () => 0, ttlMs: TTL });
  await assert.rejects(send('k1', charge));
  fail = false;
  await send('k1', charge);
  assert.equal(charges.length, 2); // por eso la clave y el efecto van en la misma transacción
});
lint.test.tsDescargar lint.test.ts
// Lo que redocly lint revisa y lo que no.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { test } from 'node:test';

function lint(file: string) {
  const result = spawnSync('redocly', ['lint', file, '--format=summary'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
  return { status: result.status, output: `${result.stdout}${result.stderr}` };
}

test('el esqueleto no tiene errores: cuatro advertencias', () => {
  const { status, output } = lint('openapi.yaml');
  assert.equal(status, 0);
  assert.match(output, /You have 4 warnings/);
});

test('un $ref a un esquema que no existe es un error', () => {
  const { status, output } = lint('lint-examples/broken-ref.yaml');
  assert.equal(status, 1);
  assert.match(output, /no-unresolved-refs/);
});

test('una especificación que no describe a Kallpa puede pasar lint sin errores', () => {
  const { status } = lint('lint-examples/wrong-but-valid.yaml');
  assert.equal(status, 0);
});
lost-response.test.tsDescargar lost-response.test.ts
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { startTestServer } from './build/kallpa/test/helpers.ts';
import { startChargesServer } from './charges-server.ts';
import { postWithRetry, startFlakyProxy } from './lost-response.ts';

test('Kallpa: se pierde la respuesta, la reserva quedó hecha y el reintento dice 409', async () => {
  const kallpa = await startTestServer();
  const proxy = await startFlakyProxy(kallpa.url);
  const booking = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
  const attempts = await postWithRetry(`${proxy.url}/api/bookings`, booking);
  assert.equal(attempts[0].kind, 'connection-lost');
  assert.ok(attempts[1].kind === 'response');
  assert.equal(attempts[1].status, 409);
  // La reserva existe y es de quien reintentó, aunque lo último que vio fue un error.
  const list = await (await fetch(`${kallpa.url}/api/members/2/bookings`)).json();
  assert.equal(list.length, 1);
  await proxy.close();
  await kallpa.close();
});

test('un POST sin regla que lo frene: el reintento cobra dos veces', async () => {
  const charges = await startChargesServer({ requireKey: false });
  const proxy = await startFlakyProxy(charges.url);
  const attempts = await postWithRetry(`${proxy.url}/charges`, { memberId: 2, cents: 8100 });
  assert.equal(attempts[0].kind, 'connection-lost');
  assert.ok(attempts[1].kind === 'response');
  assert.equal(attempts[1].status, 201);
  assert.equal(charges.charges.length, 2);
  await proxy.close();
  await charges.close();
});

test('con clave de idempotencia: un solo cobro y el reintento recibe la respuesta original', async () => {
  const charges = await startChargesServer({ requireKey: true });
  const proxy = await startFlakyProxy(charges.url);
  const attempts = await postWithRetry(
    `${proxy.url}/charges`,
    { memberId: 2, cents: 8100 },
    { 'idempotency-key': '"8e03978e-40d5-43e8-bc93-6894a57f9324"' },
  );
  assert.equal(attempts[0].kind, 'connection-lost');
  assert.ok(attempts[1].kind === 'response');
  assert.equal(attempts[1].status, 201);
  assert.deepEqual(attempts[1].body, { charge: { id: 1, memberId: 2, cents: 8100 } });
  assert.equal(charges.charges.length, 1);
  await proxy.close();
  await charges.close();
});
patterns.test.tsDescargar patterns.test.ts
// Los patrones del capítulo, cada uno con lo que promete y lo que cuesta.
import assert from 'node:assert/strict';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { test } from 'node:test';
import { fixedClock, systemClock } from './build/kallpa/src/domain/clock.ts';
import { HttpError, sendJson } from './build/kallpa/src/http/json.ts';
import { createServer } from './build/kallpa/src/http/server.ts';
import { consoleNotifier, type BookingConfirmation } from './build/kallpa/src/notifications/notifier.ts';
import { CourtRepository } from './build/kallpa/src/store/court-repository.ts';
import { openDatabase } from './build/kallpa/src/store/database.ts';
import { seedCatalog } from './build/kallpa/src/store/seed.ts';
import { messagingNotifier, type TemplateMessage } from './patterns/adapter.ts';
import { withErrors, withLog, type RawHandler } from './patterns/decorator.ts';
import { confirmBooking, createBookingEvents, type BookingConfirmedEvent } from './patterns/observer.ts';
import {
  GlobalClock,
  isMorning,
  isMorningWithSingleton,
  notifierFor,
  NotifierFactoryProvider,
} from './patterns/overkill.ts';
import { closestTo, ClosestTo, earliestFirst, EarliestFirst, suggest, suggestWithRanker } from './patterns/strategy.ts';

const slots = [8, 10, 15, 19, 20].map((hour) => ({ hour, available: true }));

async function serve(handler: RawHandler, path = '/x') {
  const server = http.createServer(handler);
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  const response = await fetch(`http://127.0.0.1:${port}${path}`);
  server.closeAllConnections();
  server.close();
  return response;
}

const failing: RawHandler = async () => {
  throw new HttpError(404, 'NOT_FOUND', 'No existe.');
};

test('strategy: la clase y la función ordenan igual, y quien las usa no cambia', () => {
  assert.deepEqual(suggest(slots, earliestFirst), suggestWithRanker(slots, new EarliestFirst()));
  assert.deepEqual(suggest(slots, closestTo(18)), suggestWithRanker(slots, new ClosestTo(18)));
  assert.deepEqual(suggest(slots, closestTo(18)), [19, 20, 15, 10, 8]);
  assert.deepEqual(suggest(slots, earliestFirst), [8, 10, 15, 19, 20]);
});

test('strategy: el reloj de Kallpa es una estrategia; fijo en las pruebas, del sistema en producción', () => {
  const clock = fixedClock('2026-03-09T15:00:00Z');
  assert.equal(clock.now().toISOString(), '2026-03-09T15:00:00.000Z');
  assert.equal(clock.now().toISOString(), '2026-03-09T15:00:00.000Z');
  assert.ok(systemClock.now() instanceof Date);
});

test('dependency injection: createServer usa el reloj, la base de datos y el notificador que recibe', async () => {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  const sent: BookingConfirmation[] = [];
  const server = createServer({
    db,
    clock: fixedClock('2026-03-09T15:00:00Z'),
    notifier: { bookingConfirmed: (c) => sent.push(c) },
    log: () => {},
  });
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  const body = { courtId: 2, memberId: 2, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
  const response = await fetch(`http://127.0.0.1:${port}/api/bookings`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  const { booking } = (await response.json()) as { booking: { createdAt: string } };
  assert.equal(booking.createdAt, '2026-03-09T15:00:00.000Z'); // el reloj que recibió
  assert.equal(sent.length, 1); // el notificador que recibió
  server.closeAllConnections();
  server.close();
  db.close();
});

test('adapter: una reserva real llega al proveedor con su formato, y las reservas no cambiaron', async () => {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  const messages: TemplateMessage[] = [];
  const server = createServer({
    db,
    clock: fixedClock('2026-03-09T15:00:00Z'),
    notifier: messagingNotifier({ sendTemplate: (m) => messages.push(m) }),
    log: () => {},
  });
  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address() as AddressInfo;
  const body = { courtId: 2, memberId: 1, date: '2026-03-17', startHour: 10, teamName: 'Los Halcones' };
  await fetch(`http://127.0.0.1:${port}/api/bookings`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  assert.equal(messages.length, 1);
  assert.equal(messages[0].to, '+51987654321');
  assert.equal(messages[0].template, 'reserva_confirmada');
  server.closeAllConnections();
  server.close();
  db.close();
});

test('repository: CourtRepository devuelve canchas; quien lo usa no ve SQL', () => {
  const db = openDatabase(':memory:');
  seedCatalog(db);
  const courts = new CourtRepository(db);
  assert.deepEqual({ ...courts.findById(3) }, { id: 3, name: 'Vóley', sport: 'voley' });
  db.close();
});

test('decorator: withErrors convierte la excepción en una respuesta JSON', async () => {
  const response = await serve(withErrors(failing));
  assert.equal(response.status, 404);
  assert.deepEqual(await response.json(), { error: { code: 'NOT_FOUND', message: 'No existe.' } });
});

test('decorator: withLog(withErrors(h)) registra el estado que recibió el cliente HTTP', async () => {
  const lines: string[] = [];
  const response = await serve(withLog((line) => lines.push(line), withErrors(failing)));
  assert.equal(response.status, 404);
  assert.deepEqual(lines, ['GET /x 404']);
});

test('decorator: al revés, withErrors(withLog(h)) registra un 200 que nunca existió', async () => {
  const lines: string[] = [];
  const response = await serve(withErrors(withLog((line) => lines.push(line), failing)));
  assert.equal(response.status, 404);
  assert.deepEqual(lines, ['GET /x 200']);
});

test('decorator: la función de adentro no cambia', async () => {
  const ok: RawHandler = async (_req, res) => sendJson(res, 200, { ok: true });
  const lines: string[] = [];
  const response = await serve(withLog((line) => lines.push(line), withErrors(ok)));
  assert.deepEqual(await response.json(), { ok: true });
  assert.deepEqual(lines, ['GET /x 200']);
});

const event: BookingConfirmedEvent = { bookingId: 1, courtId: 2, priceCents: 8100 };

test('observer: quien emite no conoce a quienes reaccionan, y se agregan sin tocarlo', () => {
  const events = createBookingEvents();
  const saved: BookingConfirmedEvent[] = [];
  const log: string[] = [];
  events.on('booking-confirmed', (e) => log.push(`whatsapp ${e.bookingId}`));
  events.on('booking-confirmed', (e) => log.push(`caja ${e.priceCents}`));
  confirmBooking(events, saved, event);
  assert.deepEqual(log, ['whatsapp 1', 'caja 8100']);
});

test('observer: emit es síncrono; los listeners corren antes de que confirmBooking vuelva', () => {
  const events = createBookingEvents();
  const order: string[] = [];
  events.on('booking-confirmed', () => order.push('listener'));
  confirmBooking(events, [], event);
  order.push('después de emitir');
  assert.deepEqual(order, ['listener', 'después de emitir']);
});

test('observer: un listener que lanza corta a los siguientes y le llega a quien emitió', () => {
  const events = createBookingEvents();
  const saved: BookingConfirmedEvent[] = [];
  const log: string[] = [];
  events.on('booking-confirmed', () => {
    throw new Error('WhatsApp no responde');
  });
  events.on('booking-confirmed', () => log.push('caja'));
  assert.throws(() => confirmBooking(events, saved, event), /WhatsApp no responde/);
  assert.equal(saved.length, 1); // la reserva ya está guardada...
  assert.deepEqual(log, []); // ...y la caja nunca se enteró
});

test('sin patrón: la fábrica con una sola implementación devuelve lo mismo que un nombre', () => {
  assert.equal(new NotifierFactoryProvider().getFactory().create(), consoleNotifier);
  assert.equal(notifierFor('console'), consoleNotifier);
  assert.notEqual(notifierFor('silent'), consoleNotifier);
});

test('singleton: lo que una prueba fija en el reloj global le llega a la siguiente', () => {
  GlobalClock.getInstance().fix('2026-03-09T15:00:00Z'); // 10:00 en Lima
  assert.equal(isMorningWithSingleton(), true);
  GlobalClock.getInstance().fix('2026-03-09T20:00:00Z'); // 15:00 en Lima
  // Otra prueba, en otro archivo, leería este valor sin saberlo.
  assert.equal(isMorningWithSingleton(), false);
});

test('con el reloj como parámetro, cada llamada dice qué hora usa y nada se comparte', () => {
  assert.equal(isMorning(fixedClock('2026-03-09T15:00:00Z')), true);
  assert.equal(isMorning(fixedClock('2026-03-09T20:00:00Z')), false);
  assert.equal(isMorning(fixedClock('2026-03-09T15:00:00Z')), true);
});

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 6: APIs y patrones de diseño. Se usa dentro del contenedor del curso.
#
#   make            copia Kallpa en build/kallpa y empaqueta openapi.yaml como
#                   JSON en build/openapi.json
#   make test       revisa los tipos (tsc), corre ESLint, redocly lint sobre
#                   el esqueleto, las pruebas (*.test.ts), que comprueban lo
#                   que dice el apunte, y los dos cambios de ejemplo sobre
#                   copias de Kallpa (make decorator y make v2)
#   make recorrido  levanta una copia de Kallpa, reserva, reintenta y cancela
#                   dos veces con curl (el paso 2 del laboratorio)
#   make retry      la respuesta perdida, con reintento: en Kallpa y en un
#                   servidor de pagos con y sin clave de idempotencia
#   make decorator  aplica decorator-server.diff a una copia de Kallpa y corre
#                   su make check
#   make v2         aplica v2-bookings.diff a una copia de Kallpa y corre su
#                   make check
#   make recorrido-v2  las dos versiones de make v2, con curl
#   make docs       genera build/api.html con redocly build-docs
#   make clean      borra build/
#
# El esqueleto del laboratorio es openapi.yaml. Los ejemplos leen la Kallpa del
# curso (../../kallpa) sin modificarla.

KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main

.PHONY: all test recorrido recorrido-v2 retry decorator v2 docs clean

all: build/kallpa/package.json build/openapi.json

build/kallpa/package.json: $(wildcard $(KALLPA)/src/*.ts $(KALLPA)/src/*/*.ts $(KALLPA)/test/*.ts $(KALLPA)/*.json $(KALLPA)/*.js $(KALLPA)/Makefile)
	rm -rf build/kallpa
	mkdir -p build
	cp -r $(KALLPA) build/kallpa
	rm -rf build/kallpa/data build/kallpa/node_modules
	mkdir -p build/kallpa/data

build/openapi.json: openapi.yaml
	mkdir -p build
	redocly bundle openapi.yaml --ext json -o build/openapi.json

test: all
	tsc -p .
	eslint .
	redocly lint openapi.yaml
	node --test "*.test.ts"
	$(MAKE) --no-print-directory decorator
	$(MAKE) --no-print-directory v2
	@if [ -f solution/openapi.yaml ]; then \
		redocly lint solution/openapi.yaml --format=summary; \
		redocly bundle solution/openapi.yaml --ext json -o build/solution-openapi.json; \
		node --test "solution/*.test.ts"; \
	else \
		echo "(sin solution/: la especificación completa se publica al final del laboratorio)"; \
	fi

recorrido: all
	bash recorrido.sh

retry: all
	node retry-demo.ts

# Un cambio de estructura: el try/catch/finally de server.ts pasa a dos
# decorators. Las pruebas de Kallpa no cambian y tienen que seguir pasando.
decorator: all
	rm -rf build/decorator
	cp -r build/kallpa build/decorator
	cd build/decorator && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
	cd build/decorator && $(GIT) apply ../../decorator-server.diff
	cd build/decorator && $(GIT) diff --stat
	$(MAKE) --no-print-directory -C build/decorator check
	cd build/decorator && test -z "$$($(GIT) status --porcelain -- test)" && echo "test/ sin cambios"

# Un cambio compatible: la ruta nueva convive con la vieja, que se anuncia con
# Deprecation y Sunset. Las pruebas viejas siguen igual; se agrega test/v2.test.ts.
v2: all
	rm -rf build/v2
	cp -r build/kallpa build/v2
	cd build/v2 && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
	cd build/v2 && $(GIT) apply ../../v2-bookings.diff
	cd build/v2 && $(GIT) add -A && $(GIT) diff --cached --stat
	$(MAKE) --no-print-directory -C build/v2 check

recorrido-v2: v2
	bash recorrido-v2.sh

docs: all
	redocly build-docs openapi.yaml -o build/api.html

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