Python para m-b-core/Prácticas/4 · SQL y mock boundary
Práctica 4 de 8

SQL con parámetros y el mock boundary

Conectas el módulo locales/ a la base legacy con legacy_db_read(sql, params), escribes SQL con parámetros nombrados, ves con tus ojos por qué un f-string con un valor es una inyección, y aprendes la regla de tests más importante de m-b-core: mockear en el binding local del módulo, nunca un nivel arriba.

concepto · el SQL es texto y los valores son parámetros; el mock va donde vive la dependencia2 sesionessqlalchemy text() · aiosqlite · LegacyReadMock · ruff S608
Construyesshared/legacy_db/, scripts/seed_legacy.py, locales/queries.py, conftest.py, TESTING.md
Igual que en m-b-corelegacy_db_read, LegacyReadMock, tests que pinean cláusulas y last_params
DistintoSQLite con aiosqlite en vez de MySQL con aiomysql, para correr en cualquier laptop
Terminas con8 tests en verde sin tocar la DB, 3 e2e contra SQLite, y un endpoint que devuelve datos reales

Objetivos

  • Escribir la capa de acceso a la legacy igual que m-b-core: una función legacy_db_read(sql, params) sobre SQLAlchemy async con text().
  • Escribir tres consultas en queries.py con :param, incluido un fragmento condicional como constante marcada como identificador.
  • Ejecutar la misma consulta con f-string y con parámetro, con ' OR 1=1 -- como entrada, y ver la diferencia.
  • Escribir LegacyReadMock y la fixture legacy_read_mock exactas a las de m-b-core y usarlas patcheando en el binding local del módulo.
  • Romper el SQL a propósito y comprobar que el test "un nivel arriba" pasa y el test en el boundary falla.
  • Activar ruff S608, entender qué marca y cómo se documenta la excepción.

Antes de empezar

Necesitas el mini-repo mesa-core-practicas con la base de las prácticas 1 y 3: pyproject.toml (ruff/mypy de m-b-core), pytest.ini con asyncio_mode = auto, requirements/, Makefile con PYTHONPATH := ., y el módulo services/api/app/locales/ con schemas.py, utils.py, routes.py y datos en memoria. Al terminar esta práctica, los datos en memoria desaparecen y las rutas leen de la legacy.

cd mesa-core-practicas
source .venv/bin/activate
export PYTHONPATH=.
pytest -q          # lo que dejó la práctica 3, en verde

Agrega a requirements/base.txt las dos dependencias de esta práctica (m-b-core usa sqlalchemy[asyncio]==2.0.37; aiosqlite es solo nuestro reemplazo local de aiomysql):

requirements/base.txt (agregar)
sqlalchemy[asyncio]==2.0.37
aiosqlite==0.20.0
pip install -r requirements/local.txt

Construcción paso a paso

Paso 1 legacy_db_read: la única puerta a la legacy

En m-b-core, shared/legacy_db/legacy_db.py:228 define async def legacy_db_read(sql: str, params: dict | None = None) -> list[dict]: "Run one read-only query and return rows as dicts. The default for reads." Todos los queries.py la importan. Nosotros escribimos la misma firma sobre SQLite:

shared/settings.py
"""Settings: the ONLY module that reads environment variables (as in m-b-core)."""

import os


class Settings:
    ENV: str = os.getenv("ENV", "dev")
    LEGACY_DB_URL: str = os.getenv("LEGACY_DB_URL", "sqlite+aiosqlite:///./legacy.db")


settings = Settings()
shared/legacy_db/legacy_db.py
"""Read access to the legacy DB.

In m-b-core this module wraps aiomysql (legacy MySQL) / asyncpg (Postgres replica).
Here it wraps SQLite via aiosqlite so the practices run on any laptop, but the
contract is the same: `legacy_db_read(sql, params) -> list[dict]`, SQL always
with named parameters (`:name`), never interpolated values.
"""

from __future__ import annotations

from typing import Any

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from shared.settings import settings

_engine: AsyncEngine | None = None


def get_engine() -> AsyncEngine:
    global _engine
    if _engine is None:
        _engine = create_async_engine(settings.LEGACY_DB_URL)
    return _engine


async def legacy_db_read(sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
    """Run one read-only query and return rows as dicts. The default for reads."""
    async with get_engine().connect() as conn:
        result = await conn.execute(text(sql), params or {})
        return [dict(row) for row in result.mappings().all()]
shared/legacy_db/__init__.py
from .legacy_db import legacy_db_read

__all__ = ["legacy_db_read"]
Por qué text() y no un ORM. Guideline 10 de m-b-core: "Queries are raw SQL via SQLAlchemy text()". text() entiende :nombre como parámetro y se lo pasa al driver ya separado del SQL: el valor nunca se concatena. result.mappings().all() devuelve filas como diccionarios: es la parte que en PHP hacías con fetch_assoc. El global _engine es un singleton perezoso: un pool por proceso, no una conexión por llamada.

Paso 2 Sembrar una legacy en miniatura

Las tablas y los datos del contrato de las prácticas: locales 11 (Lima), 873 (Santiago, sin ZonaHoraria en Local, con ella en Ubigeo), 2246 (Quito) y un 999 inactivo; reservas del 20 y 21 de agosto para el 11, una cancelada (EstadoId = 3).

scripts/seed_legacy.py
"""Create the legacy tables in SQLite and load the practice dataset."""

import asyncio
from typing import Any

from sqlalchemy import text

from shared.legacy_db.legacy_db import get_engine

DDL = [
    "DROP TABLE IF EXISTS Local",
    "DROP TABLE IF EXISTS Ubigeo",
    "DROP TABLE IF EXISTS Reserva",
    "DROP TABLE IF EXISTS Mesa",
    """CREATE TABLE Local (Id INTEGER PRIMARY KEY, Nombre TEXT, Pais TEXT, Depa TEXT,
       EstadoId INTEGER, ZonaHoraria TEXT)""",
    "CREATE TABLE Ubigeo (Pais TEXT, Depa TEXT, ZonaHoraria TEXT, EstadoId INTEGER)",
    """CREATE TABLE Reserva (Id INTEGER PRIMARY KEY, LocalId INTEGER, FechaReserva TEXT,
       Horario TEXT, EstadoId INTEGER, Pax INTEGER)""",
    "CREATE TABLE Mesa (Id INTEGER PRIMARY KEY, LocalId INTEGER, Capacidad INTEGER, DisponibleReserva INTEGER)",
]

ROWS: list[tuple[str, list[dict[str, Any]]]] = [
    (
        "INSERT INTO Local VALUES (:id, :n, :p, :d, :e, :tz)",
        [
            {"id": 11, "n": "La Mar", "p": "PE", "d": "LIM", "e": 1, "tz": "America/Lima"},
            {"id": 873, "n": "Boragó", "p": "CL", "d": "RM", "e": 1, "tz": None},
            {"id": 2246, "n": "Zazu", "p": "EC", "d": "PIC", "e": 1, "tz": None},
            {"id": 999, "n": "Cerrado", "p": "PE", "d": "LIM", "e": 0, "tz": "America/Lima"},
        ],
    ),
    (
        "INSERT INTO Ubigeo VALUES (:p, :d, :tz, :e)",
        [
            {"p": "PE", "d": "LIM", "tz": "America/Lima", "e": 1},
            {"p": "CL", "d": "RM", "tz": "America/Santiago", "e": 1},
            {"p": "EC", "d": "PIC", "tz": None, "e": 1},
        ],
    ),
    (
        "INSERT INTO Reserva VALUES (:id, :l, :f, :h, :e, :pax)",
        [
            {"id": 1, "l": 11, "f": "2026-08-20", "h": "13:00", "e": 1, "pax": 2},
            {"id": 2, "l": 11, "f": "2026-08-20", "h": "20:30", "e": 1, "pax": 4},
            {"id": 3, "l": 11, "f": "2026-08-20", "h": "21:00", "e": 3, "pax": 2},
            {"id": 4, "l": 11, "f": "2026-08-21", "h": "13:00", "e": 1, "pax": 6},
            {"id": 5, "l": 873, "f": "2026-08-20", "h": "20:00", "e": 1, "pax": 2},
        ],
    ),
    (
        "INSERT INTO Mesa VALUES (:id, :l, :c, :d)",
        [
            {"id": 1, "l": 11, "c": 2, "d": 1},
            {"id": 2, "l": 11, "c": 4, "d": 1},
            {"id": 3, "l": 11, "c": 6, "d": 0},
            {"id": 4, "l": 873, "c": 2, "d": 1},
        ],
    ),
]


async def main() -> None:
    engine = get_engine()
    async with engine.begin() as conn:
        for stmt in DDL:
            await conn.execute(text(stmt))
        for sql, rows in ROWS:
            await conn.execute(text(sql), rows)
    async with engine.connect() as conn:
        n = (await conn.execute(text("SELECT COUNT(*) FROM Local"))).scalar_one()
    print(f"seed listo: {n} locales en {engine.url.database}")


if __name__ == "__main__":
    asyncio.run(main())

Agrega el target al Makefile y corre:

Makefile (agregar)
seed:
	$(VENV)/bin/python scripts/seed_legacy.py
make seed
Deberías ver
seed listo: 4 locales en ./legacy.db
Fíjate: hasta el seed usa :param. conn.execute(text(sql), rows) con una lista de dicts es un executemany: una sentencia, N filas, cero concatenación. Y engine.begin() abre una transacción que hace commit al salir del with (o rollback si algo lanza), el with de Python es el try/finally que en PHP escribías a mano.

Paso 3 queries.py: SQL como texto, valores como parámetros

Reemplaza los datos en memoria de la práctica 3 por tres consultas. Es la forma exacta de services/api/app/cities/queries.py de m-b-core: una función async por consulta, docstring que explica el porqué del SQL, y legacy_db_read importada del paquete compartido (ese import es el binding local que vas a mockear en el paso 6).

services/api/app/locales/queries.py
"""I/O: reads from the legacy DB. No business logic here."""

from __future__ import annotations

from typing import Any

from shared.legacy_db import legacy_db_read

# Constant SQL fragment: identifier/filter text written by us, never user input.
_ACTIVE_FILTER_SQL = "AND l.EstadoId = :estado_activo"  # identifier, not input
ESTADO_ACTIVO = 1


async def fetch_local(local_id: int) -> dict[str, Any] | None:
    """One local with its Ubigeo timezone (LEFT JOIN so a missing Ubigeo still returns the row)."""
    rows = await legacy_db_read(
        """
            SELECT l.Id AS id, l.Nombre AS nombre, l.Pais AS pais,
                   u.ZonaHoraria AS zona_horaria
            FROM Local l
            LEFT JOIN Ubigeo u ON u.Pais = l.Pais AND u.Depa = l.Depa AND u.EstadoId = 1
            WHERE l.Id = :local_id
            LIMIT 1
        """,
        {"local_id": local_id},
    )
    return rows[0] if rows else None


async def fetch_locales(q: str | None = None, pais: str | None = None) -> list[dict[str, Any]]:
    """Active locals, optionally filtered by name (LIKE) and country."""
    return await legacy_db_read(
        f"""
            SELECT l.Id AS id, l.Nombre AS nombre, l.Pais AS pais,
                   u.ZonaHoraria AS zona_horaria
            FROM Local l
            LEFT JOIN Ubigeo u ON u.Pais = l.Pais AND u.Depa = l.Depa AND u.EstadoId = 1
            WHERE (:q IS NULL OR l.Nombre LIKE :q_like)
              AND (:pais IS NULL OR l.Pais = :pais)
              {_ACTIVE_FILTER_SQL}
            ORDER BY l.Nombre
        """,  # noqa: S608 — solo interpola _ACTIVE_FILTER_SQL (constante); los valores van por :param
        {"q": q, "q_like": f"%{q}%" if q else None, "pais": pais, "estado_activo": ESTADO_ACTIVO},
    )


async def fetch_reservas_del_dia(local_id: int, fecha: str) -> list[dict[str, Any]]:
    """Active reservations of a local for one date (YYYY-MM-DD), ordered by time."""
    return await legacy_db_read(
        """
            SELECT r.Id AS id, r.Horario AS horario, r.Pax AS pax
            FROM Reserva r
            WHERE r.LocalId = :local_id
              AND r.FechaReserva = :fecha
              AND r.EstadoId = :estado_activo
            ORDER BY r.Horario
        """,
        {"local_id": local_id, "fecha": fecha, "estado_activo": ESTADO_ACTIVO},
    )
Tres cosas para mirar dos veces. (1) WHERE l.Id = :local_id: el valor va en el dict, no en el string; el LIKE también: armas %mar% como valor (q_like), no lo pegas al SQL. (2) _ACTIVE_FILTER_SQL es un fragmento de SQL constante escrito por nosotros; el f-string solo lo interpola a él y el comentario # identifier, not input lo declara: es el patrón de listings/queries.py de m-b-core ({depa_filter}, {local_tipo_filter}), y el paso 8 explica el noqa. (3) La ambigüedad de zona_horaria: para el local 873 la columna Local.ZonaHoraria es NULL pero Ubigeo sí la tiene; el LEFT JOIN devuelve la fila igual (la práctica 6 usa esto para el fallback por país).

Actualiza routes.py para leer de aquí (misma forma que en la práctica 3, cambian solo los await):

services/api/app/locales/routes.py
"""GET /v1/locales, /v1/locales/{id}, /v1/locales/{id}/reservas?fecha=YYYY-MM-DD."""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse

from shared.errors import error_response
from shared.errors.codes import LOCAL_NOT_FOUND

from .queries import fetch_local, fetch_locales, fetch_reservas_del_dia
from .schemas import LocalDTO, LocalesResponse, ReservasDelDiaResponse
from .utils import to_local_dto, to_reserva_dto

router = APIRouter(tags=["locales"])


@router.get("/locales", response_model=LocalesResponse)
async def list_locales(
    q: Annotated[str | None, Query(min_length=2, max_length=60)] = None,
    pais: Annotated[str | None, Query(min_length=2, max_length=2)] = None,
) -> LocalesResponse:
    rows = await fetch_locales(q=q, pais=pais)
    return LocalesResponse(locales=[to_local_dto(r) for r in rows])


@router.get("/locales/{local_id}", response_model=LocalDTO)
async def get_local(local_id: int) -> LocalDTO | JSONResponse:
    row = await fetch_local(local_id)
    if row is None:
        return JSONResponse(
            status_code=404,
            content=error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"),
        )
    return to_local_dto(row)


@router.get("/locales/{local_id}/reservas", response_model=ReservasDelDiaResponse)
async def reservas_del_dia(
    local_id: int,
    fecha: Annotated[str, Query(pattern=r"^\d{4}-\d{2}-\d{2}$")],
) -> ReservasDelDiaResponse | JSONResponse:
    if await fetch_local(local_id) is None:
        return JSONResponse(
            status_code=404,
            content=error_response(LOCAL_NOT_FOUND, f"Local {local_id} no existe"),
        )
    rows = await fetch_reservas_del_dia(local_id, fecha)
    return ReservasDelDiaResponse(
        local_id=local_id, fecha=fecha, reservas=[to_reserva_dto(r) for r in rows]
    )

Y en schemas.py agrega los dos DTOs de reservas (ReservaDTO(id, horario, pax), ReservasDelDiaResponse(local_id, fecha, reservas)) y en utils.py el to_reserva_dto(row) correspondiente. Nada más cambia: la ruta sigue siendo delgada.

Paso 4 La inyección, en vivo, sobre tu propia base

Antes de seguir, ve la diferencia con tus ojos. Este script corre la misma consulta de dos formas con la entrada clásica ' OR 1=1 --:

scripts/demo_inyeccion.py
"""Por qué NUNCA se interpola un valor en el SQL: la misma consulta, con y sin parámetro."""

import asyncio

from shared.legacy_db import legacy_db_read


async def main() -> None:
    entrada = "' OR 1=1 --"  # lo que un cliente malicioso manda como ?q=

    print("1) f-string con el valor dentro (MAL):")
    sql_mal = f"SELECT Id, Nombre FROM Local WHERE Nombre = '{entrada}'"  # noqa: S608
    print("   SQL enviado:", sql_mal)
    print("   filas:", await legacy_db_read(sql_mal))

    print("2) parámetro nombrado (BIEN):")
    sql_bien = "SELECT Id, Nombre FROM Local WHERE Nombre = :nombre"
    print("   SQL enviado:", sql_bien, "| params:", {"nombre": entrada})
    print("   filas:", await legacy_db_read(sql_bien, {"nombre": entrada}))


if __name__ == "__main__":
    asyncio.run(main())
python scripts/demo_inyeccion.py
Deberías ver
1) f-string con el valor dentro (MAL):
   SQL enviado: SELECT Id, Nombre FROM Local WHERE Nombre = '' OR 1=1 --'
   filas: [{'Id': 11, 'Nombre': 'La Mar'}, {'Id': 873, 'Nombre': 'Boragó'}, {'Id': 999, 'Nombre': 'Cerrado'}, {'Id': 2246, 'Nombre': 'Zazu'}]
2) parámetro nombrado (BIEN):
   SQL enviado: SELECT Id, Nombre FROM Local WHERE Nombre = :nombre | params: {'nombre': "' OR 1=1 --"}
   filas: []
Léelo despacio. En (1) el texto del cliente cambió la consulta: cerró la comilla, agregó OR 1=1 y comentó el resto; devolvió toda la tabla, incluido el local inactivo. En (2) el mismo texto viajó como valor: la base buscó un local que se llame literalmente ' OR 1=1 -- y no hay ninguno. Es el vicio 10 de la guía de criterio (92 whereRaw("… $var …") en l-home-restaurantes) reducido a diez líneas.

Paso 5 La API con datos reales

uvicorn services.api.app.main:app --port 8000 --reload
# en otra terminal:
curl -s -w " → %{http_code}\n" "http://127.0.0.1:8000/v1/locales?pais=PE"
curl -s -w " → %{http_code}\n" "http://127.0.0.1:8000/v1/locales/873"
curl -s -w " → %{http_code}\n" "http://127.0.0.1:8000/v1/locales/12345"
curl -s -w " → %{http_code}\n" "http://127.0.0.1:8000/v1/locales/11/reservas?fecha=2026-08-20"
curl -s -w " → %{http_code}\n" "http://127.0.0.1:8000/v1/locales/11/reservas?fecha=hoy"
Deberías ver
{"locales":[{"id":11,"nombre":"La Mar","pais":"PE","zona_horaria":"America/Lima"}]} → 200
{"id":873,"nombre":"Boragó","pais":"CL","zona_horaria":"America/Santiago"} → 200
{"success":false,"error":"LOCAL_NOT_FOUND","message":"Local 12345 no existe"} → 404
{"local_id":11,"fecha":"2026-08-20","reservas":[{"id":1,"horario":"13:00","pax":2},{"id":2,"horario":"20:30","pax":4}]} → 200
{"detail":[{"type":"string_pattern_mismatch","loc":["query","fecha"],"msg":"String should match pattern '^\\d{4}-\\d{2}-\\d{2}$'","input":"hoy",…}]} → 422

La reserva 3 (cancelada, EstadoId = 3) no aparece: el filtro :estado_activo hizo su trabajo. El 404 tiene la forma de error_response de m-b-core; el 422 lo produce FastAPI solo, por el pattern del Query.

Paso 6 LegacyReadMock y los tests que no tocan la base

Un test de queries.py no necesita SQLite ni MySQL: necesita comprobar que la función arma el SQL y los parámetros correctos. Para eso m-b-core tiene LegacyReadMock en services/api/app/conftest.py: un callable async que registra cada llamada. Cópialo tal cual (los nombres importan: cuando hagas un PR en m-b-core usarás estos mismos):

services/api/app/conftest.py
"""Shared fixtures (same contract as m-b-core services/api/app/conftest.py)."""

from __future__ import annotations

import pytest


class LegacyReadMock:
    """Async callable that records calls to `legacy_db_read`.

    Usage:
        async def test_X(legacy_read_mock, monkeypatch):
            legacy_read_mock.return_value = [{"Id": 1}]
            monkeypatch.setattr("services.api.app.locales.queries.legacy_db_read", legacy_read_mock)
            await fetch_X("PE")
            assert legacy_read_mock.last_params == {"country_code": "PE"}
    """

    def __init__(self) -> None:
        self.return_value: list[dict] = []
        self.side_effect: BaseException | None = None
        self.call_count: int = 0
        self.calls: list[tuple[str, dict | None]] = []

    @property
    def last_sql(self) -> str:
        if not self.calls:
            raise AssertionError("legacy_db_read was never called")
        return self.calls[-1][0]

    @property
    def last_params(self) -> dict | None:
        if not self.calls:
            raise AssertionError("legacy_db_read was never called")
        return self.calls[-1][1]

    async def __call__(self, sql, params=None):
        self.call_count += 1
        self.calls.append((str(sql), params))
        if self.side_effect is not None:
            raise self.side_effect
        return self.return_value


@pytest.fixture()
def legacy_read_mock():
    """Yield a fresh LegacyReadMock per test. Wiring it into the SUT module is the test's job."""
    return LegacyReadMock()

Y los tests. Mira dónde va el monkeypatch: en q, el módulo queries, porque ahí es donde from shared.legacy_db import legacy_db_read creó un nombre local. Patchear shared.legacy_db.legacy_db_read no serviría: queries.py ya tiene su propia referencia.

services/api/app/locales/tests/test_queries.py
"""queries.py → patch `legacy_db_read` at the module's local binding (mock boundary rule)."""

from services.api.app.locales import queries as q

LIMA = {"id": 11, "nombre": "La Mar", "pais": "PE", "zona_horaria": "America/Lima"}


async def test_fetch_local_pasa_el_id_como_parametro(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = [LIMA]
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)

    row = await q.fetch_local(11)

    assert row == LIMA
    assert legacy_read_mock.call_count == 1
    assert legacy_read_mock.last_params == {"local_id": 11}
    assert "WHERE l.Id = :local_id" in legacy_read_mock.last_sql
    assert "LEFT JOIN Ubigeo" in legacy_read_mock.last_sql


async def test_fetch_local_devuelve_none_sin_filas(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = []
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)
    assert await q.fetch_local(999) is None


async def test_fetch_locales_filtra_por_pais_y_texto(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = [LIMA]
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)

    await q.fetch_locales(q="mar", pais="PE")

    assert legacy_read_mock.last_params == {
        "q": "mar",
        "q_like": "%mar%",
        "pais": "PE",
        "estado_activo": 1,
    }
    assert "l.Pais = :pais" in legacy_read_mock.last_sql
    assert "l.EstadoId = :estado_activo" in legacy_read_mock.last_sql
    assert "'" not in legacy_read_mock.last_sql.replace("''", "")  # no literales interpolados


async def test_fetch_reservas_del_dia_solo_activas(legacy_read_mock, monkeypatch):
    legacy_read_mock.return_value = [{"id": 1, "horario": "13:00", "pax": 2}]
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)

    rows = await q.fetch_reservas_del_dia(11, "2026-08-20")

    assert rows[0]["horario"] == "13:00"
    assert legacy_read_mock.last_params == {
        "local_id": 11,
        "fecha": "2026-08-20",
        "estado_activo": 1,
    }
    assert "r.EstadoId = :estado_activo" in legacy_read_mock.last_sql
    assert "ORDER BY r.Horario" in legacy_read_mock.last_sql


async def test_fetch_local_propaga_error_de_db(legacy_read_mock, monkeypatch):
    legacy_read_mock.side_effect = RuntimeError("legacy caído")
    monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock)
    try:
        await q.fetch_local(11)
    except RuntimeError as e:
        assert "legacy caído" in str(e)
    else:
        raise AssertionError("debió propagar")

Los tests de la ruta siguen igual que en la práctica 3 (TestClient + patch("services.api.app.locales.routes.fetch_local", new=AsyncMock(...))): la ruta importa fetch_local, así que ahí el mock correcto es esa función. Suma un test e2e marcado, que sí pega a la SQLite sembrada, y por eso queda excluido por defecto (pytest.ini: -m "not e2e"):

services/api/app/locales/tests/test_e2e_sqlite.py
"""End-to-end against the seeded SQLite (marked e2e: excluded from default runs)."""

import pytest

from services.api.app.locales import queries as q

pytestmark = pytest.mark.e2e


async def test_fetch_local_11_trae_zona_de_ubigeo():
    row = await q.fetch_local(11)
    assert row is not None and row["zona_horaria"] == "America/Lima"


async def test_fetch_local_873_sin_zona_en_ubigeo_devuelve_none_en_zona():
    row = await q.fetch_local(873)
    assert row is not None and row["zona_horaria"] == "America/Santiago"


async def test_reservas_del_dia_excluye_canceladas():
    rows = await q.fetch_reservas_del_dia(11, "2026-08-20")
    assert [r["horario"] for r in rows] == ["13:00", "20:30"]
pytest            # por defecto: sin e2e
pytest -m e2e     # contra la SQLite sembrada
Deberías ver
collected 11 items / 3 deselected / 8 selected

services/api/app/locales/tests/test_queries.py::test_fetch_local_pasa_el_id_como_parametro PASSED [ 12%]
services/api/app/locales/tests/test_queries.py::test_fetch_local_devuelve_none_sin_filas PASSED [ 25%]
services/api/app/locales/tests/test_queries.py::test_fetch_locales_filtra_por_pais_y_texto PASSED [ 37%]
services/api/app/locales/tests/test_queries.py::test_fetch_reservas_del_dia_solo_activas PASSED [ 50%]
services/api/app/locales/tests/test_queries.py::test_fetch_local_propaga_error_de_db PASSED [ 62%]
services/api/app/locales/tests/test_routes.py::test_get_local_200 PASSED [ 75%]
services/api/app/locales/tests/test_routes.py::test_get_local_404 PASSED [ 87%]
services/api/app/locales/tests/test_routes.py::test_reservas_422_si_fecha_mal_formada PASSED [100%]

======================= 8 passed, 3 deselected in 0.77s ========================

$ pytest -m e2e
services/api/app/locales/tests/test_e2e_sqlite.py::test_fetch_local_11_trae_zona_de_ubigeo PASSED
services/api/app/locales/tests/test_e2e_sqlite.py::test_fetch_local_873_sin_zona_en_ubigeo_devuelve_none_en_zona PASSED
services/api/app/locales/tests/test_e2e_sqlite.py::test_reservas_del_dia_excluye_canceladas PASSED
======================= 3 passed, 8 deselected in 0.20s ========================
Qué se está afirmando y qué no. Se pinean las cláusulas que llevan contrato (WHERE l.Id = :local_id, ORDER BY r.Horario) y los params exactos. No se compara el SQL entero: TESTING.md §2 de m-b-core lo prohíbe porque "string-equality assertions break on whitespace and column-order changes that don't affect semantics". El test de side_effect comprueba que la query no traga el error: lo deja subir (regla del vicio 12).

Paso 7 El contraejemplo: mockear un nivel demasiado arriba

Esto es lo que TESTING.md llama "the most important rule", y se entiende mejor rompiendo algo. Escribe este test temporal, que patchea fetch_local en la ruta:

services/api/app/locales/tests/test_mal_ejemplo.py (temporal, luego se borra)
from unittest.mock import AsyncMock, patch

from fastapi.testclient import TestClient

from services.api.app.main import app

client = TestClient(app)


def test_get_local_200_mockeando_la_ruta():
    with patch(
        "services.api.app.locales.routes.fetch_local",
        new=AsyncMock(return_value={"id": 11, "nombre": "La Mar", "pais": "PE", "zona_horaria": None}),
    ):
        assert client.get("/v1/locales/11").status_code == 200

Ahora rompe el SQL a propósito en queries.py (cambia WHERE l.Id = :local_id por WHERE l.Idd = :local_idd) y corre los dos tests:

sed -i '' 's/WHERE l.Id = :local_id/WHERE l.Idd = :local_idd/' services/api/app/locales/queries.py
pytest -q services/api/app/locales/tests/test_mal_ejemplo.py
pytest -q services/api/app/locales/tests/test_queries.py::test_fetch_local_pasa_el_id_como_parametro
Deberías ver (y es la lección)
# el test "un nivel arriba", con el SQL roto:
============================== 1 passed in 0.25s ===============================

# el test en el mock boundary, con el mismo SQL roto:
    assert "WHERE l.Id = :local_id" in legacy_read_mock.last_sql
E   AssertionError: assert 'WHERE l.Id = :local_id' in '\n  SELECT l.Id AS id, … WHERE l.Idd = :local_idd\n  LIMIT 1\n'
============================== 1 failed in 0.09s ===============================

El primero pasa porque el cuerpo de fetch_local nunca se ejecutó: mockeaste la función que la ruta importa, así que la query quedó sin cubrir aunque el archivo "tiene tests". El segundo la ejecuta y la atrapa. Restaura el SQL y borra el test temporal:

git checkout -- services/api/app/locales/queries.py     # (o deshaz el sed)
rm services/api/app/locales/tests/test_mal_ejemplo.py
pytest -q
Deberías ver
8 passed, 3 deselected in 0.23s
Entonces, ¿cuándo se mockea en la ruta? Cuando lo que pruebas es la ruta (códigos HTTP, forma del JSON, validación de query params), como en test_routes.py. Cuando lo que pruebas es la query, el mock va en queries.py. Regla: "mock the lowest level of the dependency; do not mock the function under test". Cada capa tiene su test, y cada test mockea justo debajo de lo que prueba.

Paso 8 ruff S608 y el TESTING.md del repo

m-b-core no tiene S608 activado (su select es E,W,F,I,B,C4,UP) y por eso los ~57 f-strings SQL del repo dependen de la revisión humana. Actívalo tú y mira qué pasa:

pyproject.toml
[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "C4", "UP", "S608"]
ruff check .
Deberías ver (sin el noqa del paso 3)
services/api/app/locales/queries.py:33:9: S608 Possible SQL injection vector through string-based query construction
   |
32 |       return await legacy_db_read(
33 |           f"""
   |  _________^
34 | |             SELECT l.Id AS id, l.Nombre AS nombre, l.Pais AS pais,
…
40 | |               {_ACTIVE_FILTER_SQL}
   | |___________^ S608
Found 1 error.

Ruff no sabe si {_ACTIVE_FILTER_SQL} es un identificador constante o un valor: por eso marca. Aquí la interpolación es legítima (una constante nuestra; los valores van por :param) y la decisión correcta es documentarla, no apagar la regla:

services/api/app/locales/queries.py (la línea que cierra el f-string)
        """,  # noqa: S608 — solo interpola _ACTIVE_FILTER_SQL (constante); los valores van por :param
ruff check . && ruff format --check . && mypy services shared scripts
Deberías ver
All checks passed!
23 files already formatted
Success: no issues found in 23 source files

Por último, deja escrito el estándar de tests del mini-repo. Es un resumen del services/api/TESTING.md de m-b-core; cuando llegues al repo real, lee el original completo:

TESTING.md
# Testing — mesa-core-practicas (resumen del TESTING.md de m-b-core)

**Principio 0:** la cobertura es consecuencia, no meta. Antes de escribir un test:
"¿qué bug de producción atraparía si se rompiera?". Si ninguno, no se escribe.

| Forma del código | Estrategia | Qué se mockea |
|---|---|---|
| Función pura (sin I/O) | `parametrize` de bordes | nada |
| `*queries.py` | patch de `legacy_db_read` **en el binding local del módulo** | `legacy_db_read` |
| Orquestador (llama varias funciones) | `AsyncMock` de cada subfunción | las subfunciones que importa |
| Ruta FastAPI | `TestClient` + patch de las funciones que la ruta importa | `services.api.app.<mod>.routes.<fn>` |

**Regla del mock boundary:** mockea el nivel más bajo de la dependencia; nunca la función
bajo prueba. Mockear un nivel arriba deja la query sin cubrir y "pasa" con el SQL roto.

**Asserts sobre SQL:** pinear cláusulas que llevan contrato (`"WHERE l.Id = :local_id" in sql`)
y `last_params`; nunca igualdad del string entero.
git add -A
git commit -m "feat(locales): leer de la legacy con legacy_db_read; tests con LegacyReadMock en el boundary"

Entiende el código: qué de esto es igual en m-b-core

Lo que hicisteDónde está en m-b-coreDiferencia
legacy_db_read(sql, params)shared/legacy_db/legacy_db.py:228 ("Run one read-only query and return rows as dicts. The default for reads")Allí envuelve aiomysql/asyncpg con AUTOCOMMIT y ruteo por tabla replicada; hay también legacy_db_read_connection() para varias queries en una conexión. Aquí, SQLite.
queries.py con una función async por consulta y docstring del porquéservices/api/app/cities/queries.py (fetch_cities)Igual. Fíjate en la docstring de fetch_cities: explica el GROUP_CONCAT y el HAVING; eso es lo que se espera de ti.
Fragmento constante interpolado con # identifier, not inputservices/api/app/listings/queries.py ({depa_filter}, {local_tipo_filter}), auth/service.py ({_SOURCE_TABLE[source]})Allí sin S608 ni noqa: la auditoría del 16-ago (M-06) recomienda activar la regla y marcar cada excepción, como hiciste.
LegacyReadMock + fixture legacy_read_mockservices/api/app/conftest.pyIdéntico (mismos atributos: return_value, side_effect, call_count, calls, last_sql, last_params).
monkeypatch.setattr(q, "legacy_db_read", …) y asserts por cláusulaTESTING.md §2 "The mock-boundary rule (most important)"; ejemplo: feed/tests/test_queries.pyIgual. En el repo hay 144 tests que patchean en el binding correcto.
Test de ruta con TestClient + patch("…routes.fetch_x")cities/tests/test_routes.py (_MOCK_PATH = "services.api.app.cities.routes.fetch_cities")Igual.
Marca e2e excluida por defectopytest.ini (addopts = -m "not e2e"), tests/smoke/test_v2_search_real_restaurants.pyIgual; allí los e2e pegan a servicios reales y se corren a mano.

Para leer más: SQLAlchemy 2 · Core connections (qué hace text() con :param), pytest · monkeypatch, unittest.mock (AsyncMock, patch), ruff · S608. Y de Python base, si algo te sonó raro: context managers (el async with), decoradores (@property, @pytest.fixture), diccionarios (las filas), alcance (el global _engine).

Con Claude

En esta práctica el riesgo con la IA es concreto: si le pides "una consulta que filtre por nombre", va a producir f"… LIKE '%{q}%'" con una probabilidad alta, porque es lo más común en internet. Y si le pides "un test para fetch_local", va a mockear en la ruta. Dile la regla antes:

Así noAgrega un filtro por nombre a la consulta de locales.Vas a recibir un f-string con el valor dentro y a tener que detectarlo tú en el diff.
Así síAgrega un filtro q a fetch_locales: el LIKE va con parámetro nombrado (:q_like, el % se arma en el dict, no en el SQL). Después, en test_queries.py, un test que patchee legacy_db_read en el módulo queries y afirme last_params y la cláusula l.Nombre LIKE :q_like. Ruff S608 tiene que quedar limpio sin apagarlo.Le diste la restricción, el boundary del mock y el criterio de aceptación.
Así noHaz que pase el test de fetch_local.Puede "arreglarlo" cambiando el assert.
Así síEl test test_fetch_local_pasa_el_id_como_parametro falla con este traceback: [pegar]. No toques el test. ¿Qué cambió en el SQL de queries.py y por qué el test de ruta no lo detectó?Es exactamente el paso 7: que te lo explique con tus palabras.

Si algo falla

ModuleNotFoundError: No module named 'shared' al correr el seed o pytest

Falta PYTHONPATH=. o no estás en la raíz del repo. make seed y make tests lo exportan; si corres a mano, export PYTHONPATH=. primero (m-b-core hace lo mismo en su Makefile).

sqlalchemy.exc.ArgumentError o NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:sqlite.aiosqlite

Falta aiosqlite en el entorno (o instalaste sqlalchemy sin el extra [asyncio]). pip install -r requirements/local.txt de nuevo. Comprueba con python -c "import aiosqlite, sqlalchemy; print(sqlalchemy.__version__)".

Los tests de test_queries.py pegan a la base de verdad (tardan, o fallan con "no such table")

El monkeypatch.setattr no está apuntando al módulo correcto. Tiene que ser q (services.api.app.locales.queries), no shared.legacy_db: la query ya tiene su propio nombre importado. Comprueba legacy_read_mock.call_count == 1 en el test; si es 0, el mock no se usó.

ruff marca S608 en tu consulta y no sabes si es legítimo

Pregunta: ¿lo que hay dentro de las llaves puede venir, hoy o mañana, de un request? Si es una constante del código (un fragmento SQL nuestro, un nombre de tabla de un Literal), documenta con # noqa: S608 — <por qué>. Si es un valor, muévelo al dict de params. Nunca desactives la regla en pyproject.toml.

PytestUnraisableExceptionWarning o "Event loop is closed" al final de la suite

El motor async quedó abierto entre tests que sí tocan la base (los e2e). Es ruido de cierre, no un fallo; m-b-core lo filtra en pytest.ini por warning concreto. Si te molesta, cierra el motor en una fixture de sesión: await get_engine().dispose().

El e2e falla porque legacy.db no existe o está vacía

Corre make seed. La SQLite es un archivo en la raíz (./legacy.db, según LEGACY_DB_URL); no está en git (agrégalo al .gitignore).

Listo cuando

  • pytest corre 8 tests en verde sin tocar ninguna base; pytest -m e2e corre 3 contra la SQLite sembrada.
  • Ninguna consulta de queries.py interpola un valor; los únicos {} dentro de SQL son constantes marcadas # identifier, not input con su noqa documentado.
  • Corriste demo_inyeccion.py y puedes explicar por qué la primera consulta devolvió toda la tabla.
  • Rompiste el SQL y viste con tus ojos que el test "un nivel arriba" pasa y el del boundary falla; después borraste el contraejemplo.
  • Tus tests afirman cláusulas y last_params, nunca el SQL entero.
  • ruff check (con S608), ruff format --check y mypy limpios; commit con type(scope): descripción.

Siguiente

En la Práctica 5 el mini-repo tiene por primera vez datos propios: Postgres en Docker Compose, un modelo con SQLModel y las dos primeras migraciones con alembic. Es la otra mitad de m-b-core: la legacy se lee con legacy_db_read; lo nuevo se escribe en Postgres.