---
name: fastapi-experto
description: >
  FastAPI con Pydantic v2, SQLAlchemy async, dependency injection avanzado y
  testing con httpx. Incluye el anti-patrón crítico MissingGreenlet (lazy loading
  en async). Cargar cuando se implementen endpoints FastAPI, schemas Pydantic v2,
  queries SQLAlchemy async, WebSockets, SSE o tests de integración con httpx.
version: "1.3.1"
evolved: true
evolved-from: "1.3.0"
evolved-at: "2026-06-04"
evolved-by: "evolucionar"
evolved-note: "3 patrones nuevos OIC v1.5 2026-06-04: handler logging defensivo con sesión SQLAlchemy desacoplada (PE-001); tests pytest cuelgan si handler abre engine con pool_pre_ping al startup sin BD + receta conftest (PE-002); tests de endpoint sin BD con dependency_overrides[get_db]=lambda:None + monkeypatch del service (PE-008)"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para proyectos Django o Flask — los patrones de ORM sync, Class-Based Views y middleware difieren fundamentalmente; cargar `django-experto` o el skill del framework correspondiente."
  - "No cargar para scripting Python o automatización que no expone endpoints HTTP — si no hay servidor ASGI/WSGI, la estructura de FastAPI no aplica; cargar `patrones-python` o `async-python`."
  - "No cargar para diseñar el esquema de base de datos sin el contexto del endpoint — si la tarea es solo modelado de datos, cargar `postgresql-experto` o `sql-optimizacion`."
  - "No cargar para auth/JWT cuando el proyecto ya tiene auth implementado y solo se necesita verificar permisos — cargar `auth-patrones` que cubre el flujo completo incluyendo RBAC."
evolvable: true  # default para skill estandar
---
# FastAPI Experto

## Cuándo NO cargar

- El proyecto usa Django — los patrones de ORM, serialización y middleware son diferentes; cargar `django-experto`.
- No hay endpoints HTTP en la tarea — si es solo scripting o automatización Python, cargar `patrones-python`.
- La tarea es solo diseño de esquema sin relación con endpoints — cargar `postgresql-experto`.

## Estructura de proyecto recomendada

```
backend/
├── app/
│   ├── main.py              # App factory, lifespan, middleware
│   ├── routers/             # Endpoints agrupados por dominio
│   ├── schemas/             # Pydantic v2 — entrada/salida de API
│   ├── models/              # SQLAlchemy ORM
│   ├── services/            # Lógica de negocio (sin commit)
│   ├── dependencies/        # Inyección de dependencias
│   └── core/
│       ├── config.py        # Settings con pydantic-settings
│       └── security.py      # JWT, hashing
```

---

## App factory con lifespan

```python
from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.connect()
    yield
    await database.disconnect()

def create_app() -> FastAPI:
    app = FastAPI(
        title=settings.APP_NOMBRE,
        version=settings.APP_VERSION,
        lifespan=lifespan,
        docs_url="/docs" if settings.DEBUG else None,
    )
    app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, ...)
    app.include_router(usuarios.router, prefix="/api/v1")
    return app

app = create_app()
```

---

## Schemas Pydantic v2

```python
from pydantic import BaseModel, Field, model_validator
from typing import Literal
from decimal import Decimal

class FacturaCreate(BaseModel):
    folio: str = Field(..., min_length=1, max_length=20)
    fecha: date
    cliente_id: str
    estatus: Literal["borrador", "emitida", "cancelada"] = "borrador"

class FacturaRead(BaseModel):
    id: str
    folio: str
    estatus: Literal["borrador", "emitida", "cancelada"]
    nombre_cliente: str

    model_config = {"from_attributes": True}

class PaginatedResponse[T](BaseModel):
    items: list[T]
    total: int
    page: int
    page_size: int
    has_next: bool
```

---

## Dependency Injection

```python
# app/dependencies/database.py
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with session_factory() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise

# app/dependencies/auth.py
async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(bearer),
    db: AsyncSession = Depends(get_db),
) -> Usuario:
    payload = decode_jwt(credentials.credentials)
    if not payload:
        raise HTTPException(status_code=401, detail="Token inválido")
    usuario = await db.get(Usuario, payload["sub"])
    if not usuario or not usuario.activo:
        raise HTTPException(status_code=401, detail="Usuario inactivo")
    return usuario

def require_role(roles: list[str]):
    async def verificar_rol(usuario: Usuario = Depends(get_current_user)) -> Usuario:
        if usuario.rol not in roles:
            raise HTTPException(status_code=403, detail=f"Rol requerido: {roles}")
        return usuario
    return verificar_rol
```

---

## Endpoints con patrones correctos

```python
router = APIRouter(prefix="/facturas", tags=["Facturas"])

# Rutas estáticas ANTES de paramétricas
@router.get("/exportar", response_model=list[FacturaRead])
async def exportar_facturas(...): ...

@router.get("/{factura_id}", response_model=FacturaRead)
async def obtener_factura(factura_id: str, db: AsyncSession = Depends(get_db)):
    query = (
        select(Factura)
        .where(Factura.id == factura_id)
        # OBLIGATORIO: selectinload para todas las relaciones usadas en el schema
        .options(selectinload(Factura.cliente))
    )
    result = await db.execute(query)
    factura = result.scalar_one_or_none()
    if not factura:
        raise HTTPException(status_code=404, detail="Factura no encontrada")
    # IDOR: validar que el recurso pertenece al usuario
    if factura.empresa_id != usuario.empresa_id:
        raise HTTPException(status_code=403, detail="Acceso denegado")
    return factura
```

---

## SQLAlchemy Async — reglas críticas

### NUNCA lazy loading en async

```python
# MAL — causa MissingGreenlet
factura = await db.get(Factura, factura_id)
print(factura.cliente.nombre)  # Error

# BIEN — selectinload explícito
query = select(Factura).where(Factura.id == factura_id).options(
    selectinload(Factura.cliente)
)
```

### Services: flush, no commit

```python
class FacturaService:
    async def crear(self, db: AsyncSession, datos: FacturaCreate, owner: Usuario) -> Factura:
        factura = Factura(**datos.model_dump(), empresa_id=owner.empresa_id)
        db.add(factura)
        await db.flush()      # Obtiene ID sin commitear
        await db.refresh(factura)
        return factura
        # NO hacer commit aquí — el endpoint es responsable
```

### lazy="selectin" para relaciones frecuentes

```python
class Factura(Base):
    # Para relaciones accedidas en casi todos los queries
    cliente: Mapped["Cliente"] = relationship(lazy="selectin")
    # Para relaciones accedidas raramente
    items: Mapped[list["ItemFactura"]] = relationship(lazy="select")
```

---

## Gotchas / Errores comunes no obvios

- **`MissingGreenlet` en endpoint que parece acceder solo a columnas cargadas**: SQLAlchemy async dispara MissingGreenlet cuando se accede a una relación lazy fuera de la sesión activa, aunque el objeto parezca "completo". Causa: el default de SQLAlchemy es `lazy="select"` — al serializar con Pydantic, si el serializer accede a `usuario.empresa.nombre`, el ORM intenta hacer una query sync desde contexto async. Solución: `selectinload(Usuario.empresa)` en la query original, o `lazy="selectin"` en el campo del modelo para relaciones que siempre se necesitan.
- **`Depends(get_current_user)` inyectado correctamente pero el endpoint igual retorna 200 sin autenticación**: el endpoint está declarado como `async def` pero el `Depends` se registró en el scope equivocado o en el router padre sin propagarse al hijo. Causa: FastAPI propaga `dependencies` de `APIRouter` a todos sus endpoints, pero solo si se pasan al `include_router` con el parámetro `dependencies=`, no al constructor del router. Solución: verificar que `app.include_router(router, dependencies=[Depends(get_current_user)])` está declarado para todos los routers protegidos.
- **El endpoint GET hace `db.commit()` y el test pasa, pero en producción los datos se modifican inesperadamente**: un GET que comitea no es idempotente — herramientas de monitoreo, crawlers de SEO o retries del cliente pueden ejecutar el GET múltiples veces. Causa: `db.commit()` en un GET activa transacciones que modifican estado. Solución: según la regla del skill, los endpoints GET NUNCA deben hacer `db.commit()` — si el endpoint necesita registrar que se accedió al recurso, usar una tarea async en background con `BackgroundTasks`.
- **Pydantic v2 `model_validator(mode='before')` silencia errores de tipo al recibir None donde se espera un dict**: Pydantic v2 convierte `None` a `{}` en algunos contextos de validación `before`, produciendo un modelo con todos los campos como `None` en lugar de fallar con `ValidationError`. Causa: el `mode='before'` recibe el valor crudo antes de la coerción de tipos; si el validator retorna el valor sin verificar, Pydantic intenta instanciar el modelo con datos inválidos. Solución: en el `model_validator(mode='before')`, verificar explícitamente que el valor no es `None` antes de procesarlo: `if values is None: raise ValueError("...")`.
- **Baseline Alembic con `create_all()` genera deuda estructural en toda la cadena de migraciones posterior**: si el baseline `0001_initial.py` usa `Base.metadata.create_all(bind=op.get_bind())` en lugar de `op.create_table(...)` explícito, cada migración posterior (0002, 0003, ...) debe asumir que el estado real del schema puede diverger del árbol de modelos declarado y volverse idempotente con helpers tipo `_column_if_missing`, `_index_if_missing`. Causa: `create_all` refleja el estado actual de los modelos Python, no un snapshot explícito del schema — si el schema de producción diverge (índices agregados a mano, columnas con defaults distintos), las migraciones posteriores fallan con errores de "already exists" o no ejecutan acciones que asumen que el estado previo era el declarado. Solución: **nunca** usar `create_all()` en migraciones de Alembic. Baseline debe tener `op.create_table(...)` explícito por cada tabla del schema inicial. Si el proyecto ya tiene esta deuda, dos opciones: (a) migraciones posteriores 100% idempotentes con `IF NOT EXISTS` y helpers, (b) regenerar baseline con `alembic revision --autogenerate` tras un `alembic stamp head` a una base limpia y squash del historial.
- **`tag.endswith("1")` para parsear count de asyncpg DELETE/UPDATE matchea falsos positivos**: `conn.execute("DELETE FROM ...")` retorna un string `"DELETE N"` donde N es el número de filas afectadas. Usar `tag.endswith("1")` para verificar "exactamente 1 fila afectada" matchea **incorrectamente** `"DELETE 10"`, `"DELETE 11"`, `"DELETE 21"`, `"DELETE 100"`. Aunque el query actual sea `WHERE id = $1` (PK, máximo 1 fila), un refactor futuro a `WHERE programa_id = $1` haría que el método retornara True silenciosamente para 10+ filas. Causa: `endswith` opera sobre sufijo textual, no parsea el número. **El formato del tag varía por comando**: `"UPDATE N"` y `"DELETE N"` tienen 2 partes, pero `"INSERT oid N"` tiene 3 partes (asyncpg/PostgreSQL siempre incluye el oid en INSERT, generalmente `0` en tablas modernas sin OIDs). Usar `len(partes) == 2` falla para INSERT. Solución portable que cubre los tres casos: leer el **último** componente, que siempre es el count: `partes = tag.split(); afectadas = int(partes[-1]) if len(partes) >= 2 and partes[-1].isdigit() else 0`. Para verificar "exactamente 1": `afectadas == 1`. Aplicable a UPDATE, DELETE e INSERT por igual.
- **`return result or {}` después de UPDATE/INSERT enmascara errores de BD silenciosamente**: patrón típico `result = await repo.actualizar_estatus(...); return result or {}` devuelve `{}` al cliente cuando el UPDATE no encontró la fila (race condition, RLS, FK violado). El cliente recibe HTTP 200 con body vacío en lugar del 404/500 esperado, los bugs quedan invisibles en monitoring. Causa: `or {}` trata `None` como "datos no disponibles" cuando en realidad significa "el UPDATE falló post-INSERT". Solución: explicit None check con raise — usar `if result is None: raise HTTPException(404, "Recurso no encontrado")` cuando el ID viene del cliente, o `raise HTTPException(500, "Error interno...")` cuando es un invariante post-INSERT (la fila acaba de crearse, debe existir).
- **`detail=str(exc)` en HTTPException — solo aceptable cuando la excepción es de DOMINIO con mensaje diseñado para usuario**: las excepciones de capa externa (MinIO/S3, BD driver, HTTP client de un PSP, parser PDF) tienen mensajes que pueden contener bucket/host/paths/credenciales parciales/stack traces. Pasar `str(exc)` directo al `detail` los expone al cliente. Causa: tratar todas las excepciones igual sin distinguir dominio (controlado) de capa externa (no controlado). Solución: dos patrones distintos. Para excepciones de dominio (`MIMENoPermitidoError("MIME 'X' no permitido")`, `EmailDuplicadoError`): `except DominioError as exc: raise HTTPException(422, detail=str(exc))` OK. Para capa externa (`ErrorEvidencia`, `boto3.ClientError`, `httpx.RequestError`): `except CapaExternaError as exc: logger.exception("contexto"); raise HTTPException(502, detail="Error genérico al cliente")`. La diferencia es que el mensaje de DominioError fue diseñado para el usuario; el de CapaExternaError no.
- **Vocabulario interno (nombres de funciones PL/pgSQL, schemas, tablas, "RLS", "transaccional") en `detail` al cliente fuga arquitectura**: detail genérico al cliente y detail con detalles de infraestructura para diagnóstico **no son lo mismo**. Patrones de fuga típicos: `detail=f"fn_evaluar_X no retornó resultado"` (revela nombre de función PL/pgSQL), `detail="Posible inconsistencia transaccional o RLS"` (revela motor + capa de seguridad), `detail=f"Recurso {id} no encontrado en activacion tras INSERT"` (revela UUID interno y secuencia de operaciones). Causa: el desarrollador escribe el mensaje pensando en debug, no en exposición. Solución: detail al cliente = mensaje genérico orientado al recurso ("Error interno al activar el recurso. Contacte al administrador."); detalles internos solo en `logger.error("contexto detallado %s %s", uuid, programa_id, ...)` con format strings estructurados (NO concatenación con `+`). Aplica a todos los HTTPException 500/503; el 404/422 puede ser más específico si el mensaje es del dominio.
- **`response_model=dict` produce `{[key:string]:unknown}` en `openapi-typescript` — tipos inútiles para frontend**: declarar endpoints con `response_model=dict` (placeholder) hace que el codegen `openapi-typescript` genere responses tipados como objeto vacío. El frontend lee campos via `.id`, `.monto` con type assertions implícitas → mismatches silenciosos en runtime cuando los nombres del backend cambian. Causa: FastAPI sin schema concreto no documenta el shape en `/openapi.json`. Fix: SIEMPRE declarar `response_model=EnvelopeResponse[Schema]`, `EnvelopePaginatedResponse[Schema]`, `EnvelopeOffsetResponse[Schema]` o `EnvelopeResponse[MensajeResponse]` con un schema Pydantic concreto. Genéricos `EnvelopeResponse[T] / EnvelopePaginatedResponse[T] / EnvelopeOffsetResponse[T] / MensajeResponse` deben vivir en `app/common/response.py` (Pydantic v2 + Generic[T]). Excepción única documentable: `StreamingResponse` (PDF/CSV) puede usar `response_model=dict` con comentario explicativo. Caso real: 155 endpoints SIGM refactorizados (2026-05-10) tras descubrir que el codegen producía tipos vacíos por `response_model=dict` heredado.
- **Pydantic + PostgreSQL `RETURNING *` no soporta JOINs en INSERT/UPDATE — refactor a 2 queries**: para mutaciones que devuelven un schema enriquecido con JOINs (ej: `cajero_nombre` desde `usuario.usuario`, `clave_catastral` desde `cuenta_predial`), `INSERT/UPDATE ... RETURNING *` no permite agregar JOINs. Causa: PostgreSQL `RETURNING` solo accede a las columnas de la tabla afectada. Fix: refactorizar a 2 queries: (1) `INSERT/UPDATE ... RETURNING id`; (2) `SELECT ... FROM tabla LEFT JOIN ... WHERE id = $1`. Costo: 1 round-trip extra (sub-1ms en LAN). Beneficio: el método siempre devuelve el shape enriquecido, mappers consistentes entre `crear`, `obtener` y `listar`. Patrón DRY: extraer la query SELECT a constante de clase (`_SQL_X_ENRIQUECIDA`) para reusar entre métodos. Caso: `crear_solicitud_descuento`, `autorizar_descuento`, `obtener_solicitud_descuento` y `listar_solicitudes_pendientes` comparten `_SQL_SOLICITUD_ENRIQUECIDA` con LEFT JOIN a `cuenta_predial` + `usuario` (cajero/supervisor).

- **`setattr(modelo, "col_inventada", valor)` con whitelist `_CAMPOS_MUTABLES` que referencia columnas inexistentes — bypass silencioso de persistencia**: extensión del gotcha "getattr con nombre inventado" al lado opuesto (escritura). Caso real SIGAF 2026-05-15 NEM-074 F-01: `OficioCedulaService._CAMPOS_MUTABLES = frozenset({"cuerpo_texto", "documento_firmado_subido_at", ...})` con strings que NO eran columnas del modelo (reales: `"contenido"`, `"documento_firmado_fecha"`). El endpoint `PATCH /oficios/cedulas/{id}` aceptaba el payload, ejecutaba `setattr(cedula, "cuerpo_texto", ...)` SIN error (Python permite asignar atributos arbitrarios), pero SQLAlchemy NO persistía nada porque el atributo no era columna mapeada. Respuesta HTTP 200 con body actualizado, BD intacta. Bug invisible en monitoring. Causa: `setattr` opera sobre `__dict__` del objeto Python, no sobre el mapeo SQLAlchemy. Solución preventiva: verificar la whitelist contra el modelo ANTES de declararla. Patrón de auditoría:
```bash
# Listar columnas reales del modelo
grep -nE "^\s*\w+\s*:\s*Mapped" backend/app/modules/<modulo>/models.py | awk -F: '{print $2}' | awk '{print $1}'

# Comparar contra la whitelist en el service
grep -nE "_CAMPOS_MUTABLES" backend/app/modules/<modulo>/service.py
```
Test obligatorio para CADA whitelist `_CAMPOS_MUTABLES_*`:
```python
def test_campos_mutables_son_columnas_reales():
    columnas_modelo = {col.key for col in OficioCedula.__mapper__.columns}
    no_existentes = OficioCedulaService._CAMPOS_MUTABLES - columnas_modelo
    assert not no_existentes, f"Whitelist con campos inexistentes: {no_existentes}"
```
Extender este gotcha al UNIVERSO de strings que referencian columnas ORM en código Python: `getattr(modelo, "X", default)` (default silencioso), `setattr(modelo, "X", v)` (asigna a namespace, NO persiste), `frozenset({"X", ...})` en whitelist (pasa al setattr ciego), `text("INSERT INTO X (col) ...")` (falla en runtime), `Model.X` en `where()` (AttributeError build-time — único que falla rápido). Los 4 primeros son silenciosos; el último el único que avisa.

- **`_CAMPOS_MUTABLES` declarado como variable local del método se re-crea en cada invocación y no es inspeccionable** (patrón positivo SIGAF DT-VERIF-2 2026-05-15): whitelists locales son no auditables desde tests y duplican definición en archivos hermanos. Patrón correcto: declarar como `ClassVar[frozenset[str]]` de la clase del service.
```python
from typing import ClassVar

class CedulasService:
    _CAMPOS_MUTABLES_CEDULA_PRELIMINAR: ClassVar[frozenset[str]] = frozenset({
        "fecha_emision", "fecha_notificacion", "contenido",
    })
    _CAMPOS_MUTABLES_CEDULA_DEFINITIVA: ClassVar[frozenset[str]] = frozenset({...})
    _CAMPOS_MUTABLES_OBSERVACION_DERIVADA: ClassVar[frozenset[str]] = frozenset({...})

    @staticmethod
    async def actualizar_cedula_preliminar(db, data, cedula_id):
        cedula = await db.get(CedulaPreliminar, cedula_id)
        for campo, valor in data.model_dump(exclude_unset=True).items():
            if campo not in CedulasService._CAMPOS_MUTABLES_CEDULA_PRELIMINAR:
                logger.warning("campo_no_mutable_ignorado", campo=campo)
                continue
            setattr(cedula, campo, valor)
```
Beneficios:
- Inspección desde tests: `assert "estatus" not in CedulasService._CAMPOS_MUTABLES_CEDULA_PRELIMINAR`.
- Frozenset NO se re-crea en cada invocación (eficiencia marginal pero medible en endpoints high-throughput).
- Documentación implícita: las whitelists son parte del contrato público de la clase.
- Anti-duplicación: si dos services hermanos requieren la misma whitelist, consolidarla en una clase base común.

Regla: para cualquier whitelist usada en `setattr` ciego (PATCH endpoints, factory methods, deserializadores manuales), declarar como `ClassVar[frozenset[str]]` de la clase del service. NUNCA como variable local del método. NUNCA como módulo-level constant fuera de la clase (pierde el namespacing).

- **Handler logging que persiste en BD durante manejo de excepciones — SIEMPRE usar sesión SQLAlchemy desacoplada + silenciar fallos** (patrón portable; caso real OIC v1.5 BitacoraErrorHandler 2026-06-04): si un `logging.Handler` custom (audit trail, Sentry-style, métricas async, observabilidad) usa la sesión del request para persistir el evento, durante una excepción HTTP la sesión está en estado roto y `session.add()` falla, perdiendo la excepción original. Patrón correcto:
```python
# Engine PROPIO independiente del pool del request (pool=2 suficiente).
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=2, max_overflow=2)
session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)

class MiHandler(logging.Handler):
    def emit(self, record):
        try:
            self._emit_unsafe(record)
        except Exception:  # noqa: BLE001 — silencio defensivo intencional
            # NUNCA loggear con `logging` desde aquí (riesgo de recursión infinita).
            pass

    def _emit_unsafe(self, record):
        sess = session_factory()  # sesión NUEVA, no la del request
        try:
            sess.add(entry); sess.commit()
        finally:
            sess.close()
```
Reglas clave: (a) engine propio; (b) `try/except: pass` agresivo en `emit()`; (c) NUNCA emitir a logger propio; (d) filtrar loggers ruidosos por nombre (`sqlalchemy.*`, `uvicorn.access`, `httpx`, `httpcore`, `asyncio`, `urllib3`); (e) idempotente: verificar si ya está en `root.handlers` antes de registrar. Aplicable a Sentry-style integrations, audit handlers, métricas async, alertas.

- **Tests pytest se cuelgan al importar `app.main` cuando un handler abre engine BD con `pool_pre_ping=True` al startup** (gotcha crítico OIC v1.5 2026-06-04): si un handler de logging custom se instala en `setup_logging()` y construye un engine con `pool_pre_ping=True`, sin PostgreSQL corriendo SQLAlchemy intenta una query de verificación en `socket.connect()` infinito. El test no falla — se cuelga sin output (ni siquiera el header de pytest). Causa: el bloqueo ocurre en la **importación** del módulo `app.main` (que llama `setup_logging()`), no en el test mismo. Síntoma típico: 3+ invocaciones de `pytest` en background sin output, requiere matar `python.exe` manualmente. Solución obligatoria en `backend/tests/conftest.py`:
```python
# ANTES de importar app.main
os.environ.setdefault("ENV", "test")
os.environ.setdefault("SKIP_DB_HEALTHCHECK", "true")
# Por CADA handler del proyecto que abre engine al startup:
os.environ.setdefault("SWL_BITACORA_ERRORES_ENABLED", "false")
# (sustituir por la env var real del proyecto)
```
Regla: cualquier handler con `pool_pre_ping=True` en su engine debe tener un kill-switch env var, y el conftest debe deshabilitarlo. Sin esto, los tests del proyecto serán inviables sin BD real.

- **Tests de endpoint sin BD con `dependency_overrides[get_db]=lambda:None` + monkeypatch del service** (patrón portable validado OIC v1.5 Slice 4 2026-06-04): para tests de endpoint que verifican contrato HTTP (auth, validación de query params, estructura de respuesta, rate-limit, headers de export CSV) sin requerir PostgreSQL. 25 tests en 0.23s.
```python
@pytest.fixture
def client_admin(monkeypatch: pytest.MonkeyPatch) -> TestClient:
    app.dependency_overrides[requiere_admin] = _fake_admin  # stub user
    app.dependency_overrides[get_db] = lambda: None  # type: ignore[return-value]
    # Mock del service: el endpoint llama service.metodo(db, ...) pero `db` es None;
    # el service no debe ejecutarse — se reemplaza con fake que devuelve schema válido.
    from app.services import mi_service
    monkeypatch.setattr(mi_service, "listar_paginado",
        lambda db, filtros, page, per_page: SchemaPage(items=[], total=0, ...))
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()
```
Aplicabilidad: cualquier endpoint que dependa de `get_db` + service mockeable. NO requiere BD real ni fixtures de datos. Tests de integración con BD viven aparte (`@pytest.mark.integration`).

## Referencias especializadas

| Tema | Archivo |
|------|---------|
| Middleware, pydantic-settings, JWT, CORS avanzado | [recursos/referencia-completa.md](recursos/referencia-completa.md) |
| Depends() avanzado, Annotated, security schemes, DI en tests | [recursos/dependency-injection.md](recursos/dependency-injection.md) |
| MissingGreenlet, selectinload, WebSockets, SSE, connection pooling | [recursos/async-patterns.md](recursos/async-patterns.md) |
| AsyncClient, fixtures, mocks, pytest-asyncio, coverage | [recursos/testing-httpx.md](recursos/testing-httpx.md) |
