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:

  1. Identifica qué tipo de error es
  2. Usa el stack trace para diagnosticar
  3. 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:

  1. Reproduce el error con diferentes búsquedas
  2. Aisla qué tareas causan el crash
  3. 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:

  1. Reproduce el error
  2. Identifica por qué falla y por qué los datos son incorrectos
  3. 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:

  1. Reproduce con un PATCH que solo envía un campo
  2. Verifica qué otros campos cambian
  3. 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:

  1. Reproduce: crea una tarea con una fecha imposible
  2. Investiga por qué el validador no atrapa la fecha inválida
  3. 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:

  1. No valida que la fecha sea futura. Puedes crear una tarea con due_date en el pasado.

  2. Parsea pero no usa el resultado. El validador hace parsed = datetime.strptime(v, "%Y-%m-%d") y después return v — devuelve el string original, no el datetime. Si quisieras comparar con la fecha actual, tendrías que parsear de nuevo.

  3. 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

  1. FastAPI — Tutorial Completo - Referencia para entender los endpoints y validaciones
  2. Pydantic v2 — Validators - Documentación de validadores como los usados en TaskCreate
  3. Python — datetime Module - Referencia de fechas y timestamps
  4. Real Python — Python Debugging - Técnicas de debugging para cuando Claude Code no basta
  5. 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