Módulo 6: Debugging con Claude Code
Ejercicio: Debugging Real
Ejercicio: Debugging Real
Descripción de la cápsula
Este es el ejercicio integrador del módulo 6. Hasta ahora has aprendido log analysis, interpretación de stack traces, el proceso sistemático de debugging, y cuándo Claude Code no ayuda. Ahora vas a aplicar todo junto en una aplicación FastAPI con 5 bugs de diferentes tipos.
No es un ejercicio artificial. Los bugs que encontrarás son del tipo que AI realmente genera: un runtime error por no manejar None, un error de lógica donde el código hace lo contrario de lo que debería, un edge case que crash con inputs válidos pero inesperados, un bug de rendimiento por queries ineficientes, y un error silencioso donde los datos se corrompen sin generar excepciones.
Para cada bug, debes seguir el proceso: reproducir → aislar → diagnosticar → fix → verificar. Y debes documentar tu proceso — eso es tan importante como el fix.
La Aplicación: TaskFlow API
TaskFlow es una API de gestión de tareas con usuarios, categorías, y estadísticas. Tiene 4 archivos principales. Lee todo el código antes de empezar a debuggear.
Archivo 1: models.py
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime, date
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class TaskStatus(str, Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class UserCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: str
role: str = "member"
class UserResponse(BaseModel):
id: int
name: str
email: str
role: str
created_at: datetime
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
color: str = "#3498db"
class CategoryResponse(BaseModel):
id: int
name: str
color: str
task_count: int = 0
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
priority: Priority = Priority.MEDIUM
category_id: Optional[int] = None
assignee_id: Optional[int] = None
due_date: Optional[str] = None
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v):
if v is not None:
parsed = datetime.strptime(v, "%Y-%m-%d")
return v
return v
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[Priority] = None
status: Optional[TaskStatus] = None
category_id: Optional[int] = None
assignee_id: Optional[int] = None
due_date: Optional[str] = None
class TaskResponse(BaseModel):
id: int
title: str
description: Optional[str]
priority: str
status: str
category: Optional[str]
assignee: Optional[str]
due_date: Optional[str]
created_at: datetime
updated_at: Optional[datetime]
Archivo 2: database.py
from datetime import datetime
from typing import Optional
users_db: dict[int, dict] = {}
categories_db: dict[int, dict] = {}
tasks_db: dict[int, dict] = {}
next_user_id = 1
next_category_id = 1
next_task_id = 1
def seed_data():
"""Poblar la base de datos con datos de ejemplo."""
global next_user_id, next_category_id, next_task_id
# Usuarios
users = [
{"name": "Ana García", "email": "ana@taskflow.com", "role": "admin"},
{"name": "Carlos López", "email": "carlos@taskflow.com", "role": "member"},
{"name": "María Torres", "email": "maria@taskflow.com", "role": "member"},
]
for u in users:
users_db[next_user_id] = {
**u, "id": next_user_id, "created_at": datetime.utcnow()
}
next_user_id += 1
# Categorías
categories = [
{"name": "Backend", "color": "#e74c3c"},
{"name": "Frontend", "color": "#3498db"},
{"name": "DevOps", "color": "#2ecc71"},
]
for c in categories:
categories_db[next_category_id] = {
**c, "id": next_category_id
}
next_category_id += 1
# Tasks
tasks = [
{
"title": "Implementar autenticación JWT",
"description": "Agregar login/register con JWT tokens",
"priority": "high",
"status": "in_progress",
"category_id": 1,
"assignee_id": 1,
"due_date": "2026-03-20",
},
{
"title": "Diseñar dashboard",
"description": "Crear mockups del dashboard principal",
"priority": "medium",
"status": "pending",
"category_id": 2,
"assignee_id": 2,
"due_date": "2026-03-25",
},
{
"title": "Configurar CI/CD",
"description": "Pipeline de GitHub Actions",
"priority": "high",
"status": "completed",
"category_id": 3,
"assignee_id": 1,
"due_date": "2026-03-10",
},
{
"title": "Optimizar queries",
"description": None,
"priority": "low",
"status": "pending",
"category_id": 1,
"assignee_id": None,
"due_date": None,
},
{
"title": "Revisar pull requests",
"description": "PRs pendientes del equipo",
"priority": "medium",
"status": "pending",
"category_id": None,
"assignee_id": 3,
"due_date": "2026-03-15",
},
]
for t in tasks:
tasks_db[next_task_id] = {
**t,
"id": next_task_id,
"created_at": datetime.utcnow(),
"updated_at": None,
}
next_task_id += 1
Archivo 3: services.py
from datetime import datetime
from typing import Optional
from database import (
users_db, categories_db, tasks_db,
next_user_id, next_category_id, next_task_id,
)
class UserService:
def get_all(self) -> list[dict]:
return list(users_db.values())
def get_by_id(self, user_id: int) -> Optional[dict]:
return users_db.get(user_id)
def create(self, data: dict) -> dict:
global next_user_id
user = {
"id": next_user_id,
"name": data["name"],
"email": data["email"],
"role": data.get("role", "member"),
"created_at": datetime.utcnow(),
}
users_db[next_user_id] = user
next_user_id += 1
return user
class CategoryService:
def get_all(self) -> list[dict]:
return list(categories_db.values())
def get_by_id(self, cat_id: int) -> Optional[dict]:
return categories_db.get(cat_id)
def get_with_task_count(self) -> list[dict]:
result = []
for cat in categories_db.values():
count = 0
for task in tasks_db.values():
if task["category_id"] == cat["id"]:
count += 1
result.append({**cat, "task_count": count})
return result
class TaskService:
def get_all(
self,
status: Optional[str] = None,
priority: Optional[str] = None,
assignee_id: Optional[int] = None,
) -> list[dict]:
tasks = list(tasks_db.values())
if status:
tasks = [t for t in tasks if t["status"] == status]
if priority:
tasks = [t for t in tasks if t["priority"] == priority]
if assignee_id:
tasks = [t for t in tasks if t["assignee_id"] == assignee_id]
return tasks
def get_by_id(self, task_id: int) -> Optional[dict]:
return tasks_db.get(task_id)
def create(self, data: dict) -> dict:
global next_task_id
task = {
"id": next_task_id,
"title": data["title"],
"description": data.get("description"),
"priority": data.get("priority", "medium"),
"status": "pending",
"category_id": data.get("category_id"),
"assignee_id": data.get("assignee_id"),
"due_date": data.get("due_date"),
"created_at": datetime.utcnow(),
"updated_at": None,
}
tasks_db[next_task_id] = task
next_task_id += 1
return task
def update(self, task_id: int, data: dict) -> Optional[dict]:
task = tasks_db.get(task_id)
if not task:
return None
for key, value in data.items():
if value is not None:
task[key] = value
task["updated_at"] = datetime.utcnow()
return task
def delete(self, task_id: int) -> bool:
if task_id in tasks_db:
del tasks_db[task_id]
return True
return False
def get_stats(self) -> dict:
all_tasks = list(tasks_db.values())
total = len(all_tasks)
by_status = {}
for task in all_tasks:
status = task["status"]
by_status[status] = by_status.get(status, 0) + 1
by_priority = {}
for task in all_tasks:
priority = task["priority"]
by_priority[priority] = by_priority.get(priority, 0) + 1
# Calcular tasa de completitud
completed = by_status.get("completed", 0)
active = by_status.get("active", 0)
completion_rate = completed / active * 100
# Tareas vencidas
overdue = []
today = datetime.utcnow().strftime("%Y-%m-%d")
for task in all_tasks:
if task["due_date"] and task["due_date"] < today:
if task["status"] not in ["completed", "cancelled"]:
overdue.append(task["title"])
return {
"total": total,
"by_status": by_status,
"by_priority": by_priority,
"completion_rate": completion_rate,
"overdue_tasks": overdue,
"overdue_count": len(overdue),
}
def get_user_workload(self) -> list[dict]:
workload = {}
for task in tasks_db.values():
uid = task["assignee_id"]
if uid not in workload:
user = users_db.get(uid)
workload[uid] = {
"user_id": uid,
"user_name": user["name"],
"total_tasks": 0,
"pending": 0,
"in_progress": 0,
"completed": 0,
}
workload[uid]["total_tasks"] += 1
status = task["status"]
if status in workload[uid]:
workload[uid][status] += 1
return sorted(
workload.values(),
key=lambda x: x["total_tasks"],
reverse=True
)
def search(self, query: str) -> list[dict]:
results = []
query_lower = query.lower()
for task in tasks_db.values():
if query_lower in task["title"].lower():
results.append(task)
elif query_lower in task["description"].lower():
results.append(task)
return results
Archivo 4: main.py
from fastapi import FastAPI, HTTPException, Query
from typing import Optional
from models import (
UserCreate, UserResponse,
CategoryCreate, CategoryResponse,
TaskCreate, TaskUpdate, TaskResponse,
)
from services import UserService, CategoryService, TaskService
from database import seed_data, users_db, categories_db
app = FastAPI(title="TaskFlow API", version="1.0.0")
user_service = UserService()
category_service = CategoryService()
task_service = TaskService()
@app.on_event("startup")
async def startup():
seed_data()
# --- Users ---
@app.get("/api/users")
async def list_users():
return user_service.get_all()
@app.post("/api/users", status_code=201)
async def create_user(user: UserCreate):
new_user = user_service.create(user.model_dump())
return new_user
# --- Categories ---
@app.get("/api/categories")
async def list_categories():
return category_service.get_with_task_count()
# --- Tasks ---
@app.get("/api/tasks")
async def list_tasks(
status: Optional[str] = None,
priority: Optional[str] = None,
assignee_id: Optional[int] = None,
):
tasks = task_service.get_all(status, priority, assignee_id)
result = []
for task in tasks:
category_name = None
if task["category_id"]:
cat = categories_db.get(task["category_id"])
category_name = cat["name"]
assignee_name = None
if task["assignee_id"]:
user = users_db.get(task["assignee_id"])
assignee_name = user["name"]
result.append({
**task,
"category": category_name,
"assignee": assignee_name,
})
return result
@app.post("/api/tasks", status_code=201)
async def create_task(task: TaskCreate):
if task.category_id:
cat = category_service.get_by_id(task.category_id)
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
if task.assignee_id:
user = user_service.get_by_id(task.assignee_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_task = task_service.create(task.model_dump())
return new_task
@app.get("/api/tasks/stats")
async def get_stats():
return task_service.get_stats()
@app.get("/api/tasks/search")
async def search_tasks(q: str = Query(..., min_length=1)):
return task_service.search(q)
@app.get("/api/tasks/workload")
async def get_workload():
return task_service.get_user_workload()
@app.get("/api/tasks/{task_id}")
async def get_task(task_id: int):
task = task_service.get_by_id(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@app.patch("/api/tasks/{task_id}")
async def update_task(task_id: int, data: TaskUpdate):
updated = task_service.update(task_id, data.model_dump(exclude_unset=True))
if not updated:
raise HTTPException(status_code=404, detail="Task not found")
return updated
@app.delete("/api/tasks/{task_id}", status_code=204)
async def delete_task(task_id: int):
deleted = task_service.delete(task_id)
if not deleted:
raise HTTPException(status_code=404, detail="Task not found")
Los 5 Bugs
Cada bug se describe con un escenario de usuario. Tu trabajo es seguir el proceso reproducir → aislar → diagnosticar → fix → verificar para cada uno.
Bug 1: El Crash de Stats
Reporte: "Cuando llamo a GET /api/tasks/stats, el servidor devuelve un error 500."
Nivel de dificultad: Fácil
Instrucciones:
- Identifica qué tipo de error es
- Usa el stack trace para diagnosticar
- Aplica un fix que no cambie la lógica de negocio
Ver solución
REPRODUCIR:
curl http://localhost:8000/api/tasks/stats
# Response: 500 Internal Server Error
El stack trace muestra:
File "/app/services.py", line XX, in get_stats
completion_rate = completed / active * 100
ZeroDivisionError: division by zero
AISLAR:
El error ocurre siempre — no depende de inputs externos. Es un bug en la lógica interna.
DIAGNOSTICAR:
completed = by_status.get("completed", 0)
active = by_status.get("active", 0) # BUG: "active" no es un status válido
completion_rate = completed / active * 100
Los status válidos son: pending, in_progress, completed, cancelled. No existe "active". Entonces active siempre es 0, causando división por cero.
El cálculo correcto de "tareas activas" debería ser: total - cancelled o pending + in_progress + completed.
FIX:
completed = by_status.get("completed", 0)
cancelled = by_status.get("cancelled", 0)
total_active = total - cancelled
if total_active == 0:
completion_rate = 0.0
else:
completion_rate = round(completed / total_active * 100, 1)
VERIFICAR:
curl http://localhost:8000/api/tasks/stats
# ✅ Response: 200 con completion_rate correcto
# Con los datos seed: 1 completed de 4 activas = 25.0%
Bug 2: La Búsqueda que Explota
Reporte: "El endpoint de búsqueda GET /api/tasks/search?q=optimizar funciona con algunas tareas pero crashea con otras. El error parece aleatorio."
Nivel de dificultad: Medio
Instrucciones:
- Reproduce el error con diferentes búsquedas
- Aisla qué tareas causan el crash
- Diagnostica por qué unas tareas crashean y otras no
Ver solución
REPRODUCIR:
curl "http://localhost:8000/api/tasks/search?q=JWT"
# ✅ Funciona — devuelve la tarea de JWT
curl "http://localhost:8000/api/tasks/search?q=optimizar"
# ❌ 500 Internal Server Error
curl "http://localhost:8000/api/tasks/search?q=dashboard"
# ✅ Funciona
AISLAR:
¿Qué tiene de especial la búsqueda "optimizar"? Mirando los datos seed, la tarea "Optimizar queries" tiene description: None.
curl "http://localhost:8000/api/tasks/search?q=revisar"
# ¿Funciona? Sí — "Revisar pull requests" tiene description="PRs pendientes del equipo"
curl "http://localhost:8000/api/tasks/search?q=pipeline"
# ¿Funciona? Sí — "Configurar CI/CD" tiene description="Pipeline de GitHub Actions"
El bug solo aparece cuando la búsqueda NO matchea el título pero el código intenta buscar en description — y esa tarea tiene description=None.
DIAGNOSTICAR:
def search(self, query: str) -> list[dict]:
results = []
query_lower = query.lower()
for task in tasks_db.values():
if query_lower in task["title"].lower():
results.append(task)
elif query_lower in task["description"].lower(): # BUG: description puede ser None
results.append(task)
return results
Cuando task["description"] es None, None.lower() lanza AttributeError.
La búsqueda "optimizar" matchea "Optimizar queries" por el título, así que NO llega al elif. Pero si buscas algo que no está en el título de esa tarea, el código intenta buscar en description y crashea.
Espera — ¿pero "optimizar" sí matchea el título? "Optimizar" en minúsculas es "optimizar" y el título es "Optimizar queries", con "optimizar" incluido. Entonces SÍ matchea. ¿Por qué crashea?
Revisando más cuidadosamente: la búsqueda "optimizar" matchea la tarea 4 por título, pero luego SIGUE iterando y llega a una tarea donde la búsqueda falla en título y el description es None.
En realidad el bug es más simple: cuando CUALQUIER tarea tiene description=None, la búsqueda crashea al llegar a esa tarea si el query no matchea su título.
FIX:
def search(self, query: str) -> list[dict]:
results = []
query_lower = query.lower()
for task in tasks_db.values():
title_match = query_lower in task["title"].lower()
desc = task.get("description") or ""
desc_match = query_lower in desc.lower()
if title_match or desc_match:
results.append(task)
return results
VERIFICAR:
curl "http://localhost:8000/api/tasks/search?q=optimizar"
# ✅ Devuelve tarea "Optimizar queries"
curl "http://localhost:8000/api/tasks/search?q=pipeline"
# ✅ Devuelve tarea "Configurar CI/CD" (busca en description)
curl "http://localhost:8000/api/tasks/search?q=xyz"
# ✅ Devuelve lista vacía (no crashea)
Bug 3: Workload con Usuarios Fantasma
Reporte: "El endpoint GET /api/tasks/workload a veces devuelve un error 500. Cuando funciona, muestra datos incorrectos."
Nivel de dificultad: Medio
Instrucciones:
- Reproduce el error
- Identifica por qué falla y por qué los datos son incorrectos
- Nota: este bug tiene DOS problemas — el crash Y los datos incorrectos
Ver solución
REPRODUCIR:
curl http://localhost:8000/api/tasks/workload
# ❌ 500 Internal Server Error
El stack trace muestra:
File "/app/services.py", line XX, in get_user_workload
workload[uid] = {
...
"user_name": user["name"],
}
AttributeError: 'NoneType' object has no attribute '__getitem__'
AISLAR:
Mirando los datos seed, la tarea 4 ("Optimizar queries") tiene assignee_id: None. Cuando el código procesa esa tarea:
uid = task["assignee_id"] # uid = None
user = users_db.get(uid) # user = None (no hay usuario con id None)
workload[uid] = {
"user_name": user["name"], # ❌ None["name"] crashea
}
DIAGNOSTICAR:
Problema 1 (crash): El código no maneja tareas sin assignee (assignee_id=None). Intenta buscar un usuario con id=None y crashea.
Problema 2 (datos incorrectos): Incluso si arreglas el crash, el conteo de status tiene un bug:
if status in workload[uid]:
workload[uid][status] += 1
Esto solo incrementa si el status ya es una key en el diccionario. El diccionario se inicializa con "pending", "in_progress", y "completed", pero no "cancelled". Si una tarea tiene status "cancelled", simplemente no se cuenta — el total no cuadra.
FIX:
def get_user_workload(self) -> list[dict]:
workload = {}
for task in tasks_db.values():
uid = task["assignee_id"]
if uid is None:
continue
if uid not in workload:
user = users_db.get(uid)
if not user:
continue
workload[uid] = {
"user_id": uid,
"user_name": user["name"],
"total_tasks": 0,
"pending": 0,
"in_progress": 0,
"completed": 0,
"cancelled": 0,
}
workload[uid]["total_tasks"] += 1
status = task["status"]
if status in workload[uid]:
workload[uid][status] += 1
return sorted(
workload.values(),
key=lambda x: x["total_tasks"],
reverse=True
)
VERIFICAR:
curl http://localhost:8000/api/tasks/workload
# ✅ 200 OK, devuelve workload sin error
# ✅ No incluye "None" como usuario
# ✅ Los conteos suman correctamente
Bug 4: El Update que Desaparece Datos
Reporte: "Cuando actualizo una tarea con PATCH /api/tasks/{id}, algunos campos que no envié en el request se borran. Por ejemplo, si solo cambio el status, el category_id desaparece."
Nivel de dificultad: Difícil
Instrucciones:
- Reproduce con un PATCH que solo envía un campo
- Verifica qué otros campos cambian
- Este es un bug de lógica sutil — el código "funciona" pero los datos se corrompen silenciosamente
Ver solución
REPRODUCIR:
# Ver tarea actual
curl http://localhost:8000/api/tasks/1
# Resultado: {"id": 1, "title": "Implementar autenticación JWT",
# "priority": "high", "status": "in_progress", "category_id": 1,
# "assignee_id": 1, "due_date": "2026-03-20", ...}
# Actualizar solo el status
curl -X PATCH http://localhost:8000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
# Ver tarea después del update
curl http://localhost:8000/api/tasks/1
# ¿category_id sigue siendo 1? ¿assignee_id sigue siendo 1?
AISLAR:
Probando diferentes campos:
# PATCH con solo title
curl -X PATCH http://localhost:8000/api/tasks/2 \
-H "Content-Type: application/json" \
-d '{"title": "Nuevo título"}'
# Verificar: ¿los otros campos están intactos?
DIAGNOSTICAR:
El endpoint usa data.model_dump(exclude_unset=True):
@app.patch("/api/tasks/{task_id}")
async def update_task(task_id: int, data: TaskUpdate):
updated = task_service.update(task_id, data.model_dump(exclude_unset=True))
exclude_unset=True correctamente excluye campos que no se enviaron. Entonces el diccionario data solo contiene {"status": "completed"}.
El servicio:
def update(self, task_id: int, data: dict) -> Optional[dict]:
task = tasks_db.get(task_id)
if not task:
return None
for key, value in data.items():
if value is not None: # BUG: ¿qué pasa si quiero
task[key] = value # poner un campo a None?
task["updated_at"] = datetime.utcnow()
return task
El bug sutil: La condición if value is not None impide settear un campo a None intencionalmente. Si envías {"assignee_id": null} para desasignar una tarea, el null se convierte a None en Python, y la condición lo filtra — el campo NO se actualiza.
# Intentar desasignar una tarea
curl -X PATCH http://localhost:8000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"assignee_id": null}'
# El assignee_id SIGUE siendo 1 — el null fue ignorado
Pero espera — el reporte dice que los campos "desaparecen", lo cual es lo contrario de lo que encontramos (campos que NO cambian cuando deberían). Revisemos de nuevo...
Mirando más cuidadosamente el flujo: model_dump(exclude_unset=True) devuelve solo los campos enviados. Si el PATCH body es {"status": "completed"}, el dict es solo {"status": "completed"}. El for loop solo modifica status. Los otros campos deberían quedarse intactos.
El verdadero bug del reporte podría estar en el response: el endpoint de list tasks (GET /api/tasks) enriquece los datos con category y assignee names, pero el endpoint de GET individual (GET /api/tasks/{id}) devuelve los datos crudos del dict. Dependiendo de qué endpoint use el front-end para verificar, puede verse diferente.
Sin embargo, hay otro bug aquí: exclude_unset=True + if value is not None crea una inconsistencia. Si un campo tiene default None en TaskUpdate y NO se envía, exclude_unset=True lo excluye (correcto). Pero si se envía explícitamente como null, lo incluye en el dict como None, y el if value is not None lo ignora.
FIX:
def update(self, task_id: int, data: dict) -> Optional[dict]:
task = tasks_db.get(task_id)
if not task:
return None
for key, value in data.items():
task[key] = value
task["updated_at"] = datetime.utcnow()
return task
Removemos la condición if value is not None porque exclude_unset=True ya se encarga de no incluir campos no enviados. Si alguien envía explícitamente null, es porque quiere que el campo sea None.
VERIFICAR:
# Desasignar una tarea
curl -X PATCH http://localhost:8000/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"assignee_id": null}'
curl http://localhost:8000/api/tasks/1
# ✅ assignee_id es null
# Actualizar solo el status sin afectar otros campos
curl -X PATCH http://localhost:8000/api/tasks/2 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
curl http://localhost:8000/api/tasks/2
# ✅ status es "completed", otros campos intactos
Bug 5: La Validación de Fecha que No Valida
Reporte: "Puedo crear tareas con fechas imposibles como '2026-02-30' sin que la API dé error. La tarea se crea pero luego los endpoints que filtran por fecha se comportan de forma impredecible."
Nivel de dificultad: Difícil
Instrucciones:
- Reproduce: crea una tarea con una fecha imposible
- Investiga por qué el validador no atrapa la fecha inválida
- Diagnostica qué problemas causa la fecha inválida en otros endpoints
Ver solución
REPRODUCIR:
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2026-02-30"}'
# ¿Devuelve 201 o 422?
AISLAR:
Mirando el validador en models.py:
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v):
if v is not None:
parsed = datetime.strptime(v, "%Y-%m-%d") # LANZA ValueError
return v # PERO el error sube como ValidationError de Pydantic
return v
Hmm — datetime.strptime("2026-02-30", "%Y-%m-%d") debería lanzar ValueError: day is out of range for month. Y Pydantic debería convertirlo en un ValidationError y FastAPI debería devolver 422.
Probemos de nuevo:
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2026-02-30"}'
# Si devuelve 422 → el validador funciona para este caso
# Si devuelve 201 → el validador tiene un bug
Si devuelve 422, entonces el validador SÍ funciona para fechas imposibles, pero la pregunta es: ¿valida el formato pero no la semántica? Probemos:
# Fecha en formato correcto pero en el pasado
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2020-01-01"}'
# ¿Se crea? → Sí, no valida que la fecha sea futura
# Fecha en formato incorrecto
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "30-02-2026"}'
# ¿Devuelve 422? → Debería
DIAGNOSTICAR:
Los bugs reales del validador son:
-
No valida que la fecha sea futura. Puedes crear una tarea con
due_dateen el pasado. -
Parsea pero no usa el resultado. El validador hace
parsed = datetime.strptime(v, "%Y-%m-%d")y despuésreturn v— devuelve el string original, no el datetime. Si quisieras comparar con la fecha actual, tendrías que parsear de nuevo. -
Las comparaciones de fechas como strings son problemáticas. En
get_stats():
if task["due_date"] and task["due_date"] < today:
Esto compara strings, no fechas. La comparación lexicográfica de strings en formato ISO ("2026-03-15" < "2026-03-20") funciona correctamente para el mismo formato, pero es frágil.
FIX:
@field_validator('due_date')
@classmethod
def validate_due_date(cls, v):
if v is None:
return v
try:
parsed = datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError(
f"Invalid date: '{v}'. Use YYYY-MM-DD format with valid values."
)
if parsed.date() < date.today():
raise ValueError(
f"Due date cannot be in the past: {v}"
)
return v
VERIFICAR:
# Fecha imposible
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2026-02-30"}'
# ✅ 422: Invalid date
# Fecha en el pasado
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2020-01-01"}'
# ✅ 422: Due date cannot be in the past
# Fecha válida
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test", "due_date": "2026-12-31"}'
# ✅ 201: Tarea creada
# Sin fecha (opcional)
curl -X POST http://localhost:8000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Test"}'
# ✅ 201: Tarea creada sin fecha
Formato de Documentación
Para cada bug que debuggees, documenta tu proceso con este formato:
## Bug [número]: [nombre descriptivo]
### Reproducir
- Endpoint: [método + URL]
- Input: [body/params]
- Resultado esperado: [qué debería pasar]
- Resultado obtenido: [qué pasa realmente]
- Consistente: [sí/no, N de M intentos]
### Aislar
- Input mínimo que causa el error: [...]
- Qué input SÍ funciona: [...]
- Conclusión: el bug está relacionado con [...]
### Diagnosticar
- Herramienta usada: [Claude Code / pdb / logging / inspección manual]
- Archivo y línea: [...]
- Causa raíz: [...]
- ¿Claude Code ayudó? [sí/no/parcialmente — por qué]
### Fix
- Cambios realizados: [descripción]
- Tipo de fix: [raíz / parche]
- Código antes: [...]
- Código después: [...]
### Verificar
- [ ] Input original funciona correctamente
- [ ] Inputs que funcionaban antes siguen funcionando
- [ ] Edge cases probados: [lista]
- [ ] No rompe otros endpoints: [verificados]
Conexión con Proyecto
Cómo se conecta con el proyecto integrador (Módulo 8)
Este ejercicio es una versión simplificada del proyecto integrador. En el módulo 8, la aplicación será más grande (8-12 archivos), los bugs serán más variados (incluyendo hallucinations y security holes), y necesitarás combinar code review con debugging. El proceso que practicaste aquí — y la documentación que generaste — es exactamente lo que harás a mayor escala.
Troubleshooting
Problema 1: "No puedo hacer que la aplicación corra"
Causa: Faltan dependencias o hay un error de import.
Solución: Instala FastAPI y uvicorn: pip install fastapi uvicorn. Ejecuta con uvicorn main:app --reload.
Problema 2: "Encontré un bug pero no sé si es uno de los 5"
Causa: Podrías haber encontrado un bug adicional que no está en la lista. Solución: Documéntalo igual. Si encuentras bugs extra, eso demuestra buen ojo. La lista de 5 bugs es el mínimo — si encuentras más, es un plus.
Problema 3: "Mi fix para un bug rompe otro"
Causa: Los bugs pueden interactuar entre sí. Solución: Arregla los bugs en orden de independencia: empieza por los que no dependen de otros (Bug 1 y Bug 2), después los que podrían interactuar (Bug 3-5).
Problema 4: "No estoy seguro de si mi fix es correcto"
Causa: Algunos bugs tienen múltiples fixes posibles. Solución: El fix correcto es el que: (1) resuelve el problema reportado, (2) no introduce bugs nuevos, (3) maneja edge cases razonables, y (4) es un fix de raíz, no un parche. Si tu fix cumple estos 4 criterios, es correcto — aunque sea diferente a la solución sugerida.
Resumen
En este ejercicio aplicaste:
- Proceso sistemático completo: reproducir → aislar → diagnosticar → fix → verificar en 5 bugs diferentes
- Log analysis y stack traces: interpretar errores de runtime para encontrar la causa raíz
- Diferentes tipos de bugs: runtime crash (ZeroDivisionError), null handling (AttributeError), lógica incorrecta (datos corruptos), validación incompleta
- Claude Code como herramienta: para diagnosticar errores conocidos, no como oráculo
- Documentación del proceso: no solo el fix sino cómo llegaste al fix
Este módulo cierra la Phase 2. Ahora tienes un toolkit completo: code review profesional (módulos 4-5) y debugging con asistencia AI (módulo 6). En la Phase 3, agregarás herramientas avanzadas (subagents, regenerar vs editar) y aplicarás todo en el proyecto integrador.
Recursos Adicionales
- FastAPI — Tutorial Completo - Referencia para entender los endpoints y validaciones
- Pydantic v2 — Validators - Documentación de validadores como los usados en TaskCreate
- Python — datetime Module - Referencia de fechas y timestamps
- Real Python — Python Debugging - Técnicas de debugging para cuando Claude Code no basta
- Anthropic — Claude Code Best Practices - Cómo usar Claude Code efectivamente para debugging
Debugging & Code Review with Claude Code — Módulo 6, Cápsula 06 Claude Code Agentic Development Path — Guía #6 de 11