Backend Latam Radio #10

Publicado el 05/06/2026

Cerrando el ciclo: tests, limpieza y retrospectiva

El último capítulo de la fase de hardening son las dos cosas que protegen al "yo del futuro": tests que detectan regresiones, y limpieza que reduce el ruido. Y al final del post, una retrospectiva honesta sobre lo que dejé sin hacer.

Limpieza primero

Antes de escribir tests, eliminé toda la basura que ya no aportaba:

  • api/admin.py, api/models.py, api/views.py: boilerplate vacío que Django genera al hacer startapp. La app api no tiene modelos propios (todos viven en streams), tampoco admin propio, tampoco vistas que no sean viewsets.
  • api/migrations/: directorio sin migraciones reales porque la app no tiene modelos.
  • streams/views.py: idem.
  • latam_iso_codes_txt: el fichero suelto en la raíz, sin extensión, que se usaba como cheatsheet. Lo moví a streams/management/commands/data/latam_iso_codes.txt, donde el comando lo lee.
  • Imports comentados, prints, docstrings copiados de otros proyectos.

Resultado: menos archivos, menos confusión sobre dónde añadir cosas nuevas.

También añadí __init__.py en todos los subpaquetes (api/viewsets/, api/serializers/, streams/services/, etc.). En Python 3 funcionan como namespace packages sin él, pero IDEs, mypy y pytest los reconocen mejor con el archivo presente.

Tests: 22 piezas pequeñas

Configuro pytest con un único archivo en la raíz:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = latamradio.settings
python_files = tests.py test_*.py *_tests.py
addopts = -ra --strict-markers
filterwarnings =
    ignore::DeprecationWarning

Y estructuro:

streams/tests/
├── __init__.py
├── conftest.py            # FakeRadioStation para mockear el SDK
├── test_repository.py     # 5 tests
└── test_update_radios_command.py  # 4 tests

api/tests/
├── __init__.py
├── conftest.py            # fixtures make_station, make_tag
├── test_endpoints.py      # 5 tests
├── test_station_filterset.py  # 4 tests
└── test_station_uuid_serializer.py  # 4 tests

Total: 22 tests. Sin pretensión de cobertura total. Pretensión de cobertura valiosa: las piezas que más me dolerían si se rompieran sin que me entere.

El test que más me gusta

El que mockea RadioBrowser para probar el management command sin tocar la red:

# streams/tests/test_update_radios_command.py
def _fake_query_factory(stations_by_country):
    async def fake_query(self, country_code, *, limit, hide_broken):
        return stations_by_country.get(country_code, [])
    return fake_query


@pytest.mark.django_db
def test_update_radios_skips_invalid_stations_but_keeps_going():
    stations = [
        FakeRadioStation(uuid="u-ok", country_code="AR", name="Radio OK"),
        FakeRadioStation(uuid="", country_code="AR", name="sin uuid"),  # mala
        FakeRadioStation(uuid="u-ok-2", country_code="AR", name="Radio OK 2"),
    ]
    with patch(
        "streams.management.commands.update_radios.Command._query_radios",
        new=_fake_query_factory({"AR": stations}),
    ):
        out = StringIO()
        call_command("update_radios", "AR", stdout=out)

    assert Station.objects.count() == 2
    assert "errores=1" in out.getvalue()

Le digo al test: "imagina que radio-browser devuelve estas tres emisoras, una de ellas inválida". El test verifica que dos se persisten, la mala se salta, y el resumen reporta el error. Sin Internet. Sin sorpresas en CI.

El test de "POST cerrado"

@pytest.mark.django_db
def test_post_to_stations_root_is_not_allowed(api_client):
    """Phase 2 ya cerró la creación pública: el método POST sobre /stations/
    debe responder 405 (Method Not Allowed) en ReadOnlyModelViewSet."""
    response = api_client.post(
        "/api/v1/stations/", {"name": "Mali"}, format="json"
    )
    assert response.status_code == 405

Dos líneas, pero fija la regresión. Si en el futuro alguien (yo) vuelve a ModelViewSet por descuido, este test salta. La vulnerabilidad crítica que la auditoría descubrió no puede volver a colarse.

El FakeRadioStation de conftest

# streams/tests/conftest.py
from dataclasses import dataclass, field
from typing import List, Optional


@dataclass
class FakeRadioStation:
    """Espejo mínimo del objeto que devuelve `radios.RadioBrowser.stations`.
    Solo replica los atributos que consume el repositorio.
    """
    uuid: str = "00000000-0000-0000-0000-000000000000"
    change_uuid: str = ""
    name: str = "Fake FM"
    country_code: str = "ar"
    state: str = ""
    codec: str = "MP3"
    bitrate: int = 128
    url: str = "https://example.com/stream"
    url_resolved: str = "https://example.com/stream"
    favicon: str = ""
    homepage: str = "https://example.com"
    latitude: Optional[float] = None
    longitude: Optional[float] = None
    lastcheckok: bool = True
    last_check_ok_time = None
    last_check_time = None
    last_local_check_time = None
    lastchange_time = None
    language: List[str] = field(default_factory=list)
    language_codes: List[str] = field(default_factory=list)
    tags: List[str] = field(default_factory=list)

Una dataclass que imita el contrato del SDK. No hereda de la clase real (evitamos acoplarnos a la implementación), solo replica los atributos que el repositorio lee.

Validación en docker, no en el host

El proyecto vive en Docker. No tengo venv en el host. La validación entera fue:

docker compose build
docker compose run --rm --no-deps backend python manage.py check
docker compose run --rm --no-deps backend pytest
docker compose run --rm --no-deps backend bash -c \
    "cp db.sqlite3 /tmp/test.sqlite3 && \
     DATABASE_NAME=/tmp/test.sqlite3 python manage.py migrate"

Lo importante del último comando: corre la migración 0002 (la del dedupe) contra una copia de la base real, no contra una base de tests. Es la única forma de saber que el dedupe se comporta bien con los datos que voy a encontrarme en producción. Resultado: 6510 stations preservadas, 3047 tags únicos, 102 languages, 19 language codes.

Retrospectiva

Lo que hice y me sirvió:

  • Auditar antes de publicar con dos subagentes en paralelo.
  • Trocear el hardening en 9 fases pequeñas en lugar de un mega-commit. Cada fase es testeable por separado.
  • Probar la migración crítica contra una copia de los datos reales antes de marcarla como lista.
  • Documentar las decisiones a medida que las tomaba, no después. Este es el material de los posts.
  • Soportar PostGIS y SpatiaLite por env vars: SQLite local para iterar rápido, PostGIS en producción (Dokploy) para concurrencia real. La aplicación es la misma; solo cambia el .env.
  • Cache ORM con django-cacheops + Redis: los endpoints /countries/, /tags/ y los filtros más comunes se sirven en milisegundos y se invalidan solos cuando el sync escribe. Sin lockings, sin TTLs manuales en el código.
  • Endpoints /health/ y /stations/autocomplete/: el primero permite a Traefik decidir cuándo enrutar tráfico al contenedor; el segundo da búsqueda incremental para el cliente sin pagar la serialización completa de Station.

Lo que dejé como deuda consciente:

  • Celery. El Schedule de Dokploy es suficiente hoy. Si necesito validar URLs en background o disparar tareas en respuesta a eventos, lo añadiré.
  • pip-tools. Pinneo todo en requirements.txt a mano. No está mal, pero un pip-compile con un lockfile me daría reproducibilidad cuádruple.
  • Métricas y dashboards. Logs hay, métricas no. Si la app móvil empieza a tener tráfico real, tocará añadir Prometheus o algo equivalente.
  • PostgreSQL FTS (full-text search). El filtro name__icontains funciona para una base pequeña; para 100k filas, no.
  • Monitorización de la calidad del catálogo. Cuántas emisoras tienen URL muerta, cuántas con favicon roto. Tenerlo en un dashboard de admin sería útil pero no urgente.

Lo más importante que aprendí

Lo más valioso del proyecto no fue ninguna pieza concreta. Fue separar "construir" de "documentar". Tener el código terminado y auditado antes de empezar a escribir esta serie me dio dos cosas:

  1. Honestidad retrospectiva. Puedo contar lo que hice mal porque ya lo arreglé. Si lo escribiera mientras lo desarrollo, tendería a presentar todo como si lo hubiera diseñado correctamente desde el principio.
  2. Code snippets reales. Cada bloque de código en estos posts está copiado del repo, probado por los 22 tests, y aplicado a producción. No es pseudocódigo "que se entienda".

El blog acompaña al proyecto, no lo precede. Esa, creo, es la diferencia entre "build in public" y "tutorial post-hoc". Ambos legítimos. Solo es bueno saber cuál estás haciendo.


Fin de la serie. El repo está en develop, listo para el siguiente capítulo: cuando la app móvil empiece a consumir esta API y aparezcan los problemas que ahora todavía no puedo ver.