Pruebas¶
make test # ~550 tests, about 30 seconds, no network
make rust # Rust: fmt, clippy, tests (parsers on hostile HTML included)
make integration # the built image as two servers with Valkey (Docker)
La batería por defecto nunca se comunica con t.me. Cada merge a main se
despliega en producción, así que estas pruebas y las
comprobaciones de CI son lo que separa un commit roto de los
usuarios.
Snapshots del contrato¶
tests/test_snapshots.py llama a cada endpoint contra páginas de t.me
grabadas y compara la respuesta JSON completa con un archivo de
tests/snapshots/. Cualquier cambio en lo que devuelve la API (un campo
renombrado, un número distinto, una publicación que falta) hace fallar una
prueba y aparece como un diff legible. Los archivos JSON se comparan ya
analizados, así que también se fijan los bytes exactos de cada respuesta,
mediante su SHA-256 en tests/snapshots/raw_digests.json: tampoco pueden
cambiar el orden de las claves, el formato de los números ni el escapado de
las cadenas, y eso es lo que permite mover código de pydantic a Rust sin tocar
el contrato.
Las páginas grabadas están en tests/fixtures/telegram/ (gzip), con un
manifest.json que describe a qué solicitud responde cada archivo. Un
FixtureFetcher sustituye la capa de red y hace fallar la prueba ante
cualquier solicitud que no se haya grabado. El tiempo se congela con
time-machine, porque la puntuación del feed depende del «ahora».
La cobertura incluye seis canales (durov, telegram, stranaua, tginfo,
nebudetafk, nebudetgg), los dos modos del parser, la paginación en ambas
direcciones, las vistas previas, un canal inexistente, el feed y publicaciones
individuales con un botón inline, una encuesta, un sticker, una respuesta, una
vista previa de enlace y un mensaje de voz. Un feed sintético, sembrado
directamente en la caché, añade duplicados agrupados y páginas que el parser
nunca produciría (sin nombre de usuario, sin información del canal,
publicaciones sin footer ni content).
tests/test_graphql_contract.py hace lo mismo para GraphQL: el esquema
completo (SDL) en tests/snapshots/graphql/schema.graphql, un snapshot por
consulta y comprobaciones de paridad de que cada resultado de GraphQL es igual
a la respuesta REST correspondiente. tests/test_openapi_contract.py fija el
contrato REST completo, el documento OpenAPI con todas las funciones
activadas, en tests/snapshots/openapi.json.
Cambiar la salida a propósito¶
make snapshots # UPDATE_SNAPSHOTS=1 pytest tests/test_snapshots.py tests/test_graphql_contract.py tests/test_openapi_contract.py
git diff --stat tests/snapshots
git diff tests/snapshots # review every change
Cambios incompatibles¶
Un snapshot solo muestra que el contrato cambió. scripts/api_contract.py
decide si el cambio rompe a los clientes, comparando openapi.json y
schema.graphql con la base del pull request, que es lo que sirve
producción:
Es incompatible: eliminar una operación, un campo o un valor de enum, un campo
de respuesta que ahora puede faltar o tener otro tipo (null incluido), y
todo lo que rechace solicitudes aceptadas antes (un parámetro o campo
obligatorio nuevo, un tipo más estrecho, un límite más bajo). Las adiciones
son compatibles. El esquema GraphQL lo compara graphql-core
(find_breaking_changes). El workflow Guards hace fallar un pull request
con un cambio incompatible salvo que lleve la etiqueta breaking-change, que
significa una versión mayor (véase Publicar versiones).
Grabar páginas nuevas¶
make fixtures # re-record everything (changes many snapshots)
make fixtures CHANNELS="nebudetgg" # add or refresh specific channels
La grabación usa la pila de red de producción. Volver a grabar sustituye el
contenido (visualizaciones, publicaciones nuevas), así que después regenera y
revisa los snapshots. Para cubrir un tipo de publicación poco frecuente, añade
su ID a EXTRA_POSTS en scripts/record_fixtures.py.
Pruebas unitarias¶
tests/unit/ cubre las piezas por separado:
| Archivo | Cubre |
|---|---|
test_client.py |
Construcción de URL, correspondencia de códigos de estado, validación, el único reintento de red |
test_fetcher.py |
ReqwestFetcher y BrowserFetcher contra un servidor HTTP local real: gzip, cabeceras del perfil, el XHR, cuerpos POST vacíos, tiempos de espera, errores de red |
test_cache.py |
Caché dentro del proceso (límite, compresión, caducidad), backend de Redis, circuit breaker, lecturas de dos niveles, rellenos, caídas |
test_memory_trim.py |
malloc_trim periódico (solo glibc), su desactivación |
test_coalesce.py |
Compartición single-flight, propagación de errores, seguridad ante cancelaciones |
test_popularity.py |
Decaimiento de la popularidad y elección de líder en Redis (fakeredis con Lua) |
test_services.py |
Cachés de páginas y de respuestas, vistas previas, feed stale-while-revalidate, planificación del actualizador |
test_parsing.py |
El parser nativo de páginas visto desde Python: entidades, multimedia, reenvíos, aislamiento de publicaciones defectuosas |
test_feed_ranking.py |
Señales del feed y el clasificador nativo: interacción relativa, actualidad, duplicados, temas, diversidad |
test_graphql.py |
Forma del esquema de GraphQL, resolvers, errores |
test_graphql_limits.py |
Límites de GraphQL de profundidad, coste, alias, tokens y lotes |
test_api.py |
Validación, feature flags, cabeceras, límite de solicitudes, CORS, documentación |
test_runtime.py |
Ciclo de vida del actualizador integrado, logs en JSON |
test_docs_config.py |
Las traducciones de la documentación: todas las páginas presentes, configuración sincronizada con el inglés |
test_api_contract.py |
Qué cambios del contrato scripts/api_contract.py considera incompatibles |
test_layers.py |
Cada módulo del paquete tiene su sitio en las capas de import-linter |
test_properties.py |
Pruebas basadas en propiedades, véase más abajo |
Las pruebas admiten código asíncrono (pytest-asyncio en modo auto) y las
advertencias se tratan como errores.
Pruebas de propiedades y fuzzing¶
Cada página de t.me es una entrada que el servicio no controla, y la extensión
nativa se compila con panic = "abort": un pánico en un parser tumba todo el
proceso worker. La entrada generada busca lo que los casos escritos a mano no
cubren.
tests/unit/test_properties.py(hypothesis): sea cual sea la página que devuelva t.me (páginas grabadas con partes borradas, repetidas, cortadas o insertadas), cada endpoint responde 200, 404 o 502, nunca 500; los envoltorios del parser solo fallan conTelegramError; el middleware de protección responde a cualquier cabeceraX-PoW, ruta y método con una respuesta bien formada, acepta una prueba válida exactamente una vez y cuenta el límite de solicitudes con exactitud; el feed contiene cada publicación una sola vez, solo de los canales pedidos, en cualquier orden. Los ejemplos salen de una semilla fija;HYPOTHESIS_PROFILE=nightlybusca más tiempo con semillas aleatorias.rust/telegram-me-native/tests/hostile_html.rs(cargo test): cada página grabada entera y con mutaciones de semilla fija, una sopa de marcado de t.me y valores absurdos, marcado anidado a gran profundidad en un hilo con 1 MiB de pila, y tiempos de parseo que deben crecer de forma lineal (4× la entrada en menos de 9× el tiempo) con muchas publicaciones, entidades, etiquetas sin cerrar y patrones con look-ahead.HOSTILE_CASESyHOSTILE_SEEDlo hacen correr más o de otra forma.-
rust/telegram-me-native/fuzz/(cargo-fuzz, Rust nightly): un fuzzer guiado por cobertura sobre todos los parsers de páginas, sembrado con las páginas grabadas:
Memoria¶
tests/test_memory.py (marcador memory) envía miles de solicitudes
directamente a la app ASGI sobre alias de los canales grabados
(tests/workload.py) y falla cuando las asignaciones de Python o, en Linux, la
memoria residente siguen creciendo tras un calentamiento, o cuando la caché
dentro del proceso supera su límite. Forma parte de la ejecución por defecto,
así que la CI la ejecuta; en local puedes omitirla con -m "not memory". Las
entradas de la caché viven en el heap de Rust, que tracemalloc no ve: que el
límite cuenta lo que realmente ocupan lo comprueba
rust/telegram-me-native/tests/cache_memory.rs con un asignador que cuenta.
scripts/memory_profile.py muestra las cifras que hay detrás: el coste de
importación del proceso principal y de un worker, el tamaño de una página en
caché, y el RSS y las asignaciones de Python muestreados durante una carga de
trabajo larga:
El RSS se lee de /proc, así que ejecútalo en Linux para obtener cifras
significativas.
Pruebas de integración¶
tests/integration/ ejecuta la imagen construida tal como corre en
producción, algo que las pruebas anteriores solo imitan con fakeredis o
dentro de un proceso: tests/integration/compose.yaml arranca dos servidores
con dos workers de granian cada uno, que comparten un Valkey real, delante de
un t.me falso (tests/integration/fake_telegram.py sirve las páginas grabadas
y cuenta las solicitudes; los servidores llegan a él mediante
TELEGRAM_BASE_URL).
| Prueba | Demuestra |
|---|---|
test_second_server_reads_the_first_servers_cache |
la caché de dos niveles: una página que obtiene un servidor la sirve el otro desde Redis |
test_concurrent_misses_on_both_servers_fetch_once |
el lease de descarga: 24 fallos simultáneos en 4 procesos, una sola solicitud a t.me |
test_shared_rate_limit_counts_a_client_on_every_server |
RATE_LIMIT_SHARED: un cliente queda limitado aunque ningún proceso vea por sí solo suficientes solicitudes |
test_proof_of_work_from_one_server_is_accepted_by_the_other |
un desafío de un servidor resuelve una solicitud al otro; repetida, falsificada |
test_rolling_upgrade.py |
la versión anterior (TME_PREVIOUS_IMAGE) y la nueva leen las entradas de caché de la otra |
test_valkey_outage.py |
Valkey se detiene: ambos servidores siguen respondiendo; vuelve: comparten de nuevo |
make docker
TME_TEST_IMAGE=telegram-me:dev TME_PREVIOUS_IMAGE=docker.io/koval01/telegram-me:latest make integration
Solo hablan HTTP y Docker, así que se ejecutan sin instalar el proyecto
(uv run --only-group integration) y se omiten sin TME_TEST_IMAGE.
Scripts de despliegue¶
tests/deploy/test_remote_deploy.py ejecuta deploy/remote-deploy.sh en el
Docker local como en un servidor de producción (deploy/compose.prod.yaml,
con Valkey y el t.me falso accesibles a través del gateway de la pila): la
actualización desde la versión anterior, una versión que nunca llega a estar
sana, una versión sana que falla en cada solicitud de página (solo la prueba
de humo lo nota), la comprobación canary (--verify), --rollback y el
perfil proxy con Caddy. tests/deploy/test_rollout.py ejecuta
deploy/rollout.sh, la lógica de flota del workflow de despliegue, con ssh
y scp simulados: primero el canary, y vuelta atrás de cada servidor que
recibió una versión cuando falla uno posterior, el canary o la prueba de humo
pública. Necesita bash 4.4 o posterior y forma parte de make test.
Umbrales de cobertura¶
La CI falla cuando la cobertura de líneas baja de un umbral: fail_under en
pyproject.toml para Python, [package.metadata.coverage] fail-under-lines
en rust/telegram-me-native/Cargo.toml para Rust (cargo llvm-cov). El
workflow Guards hace fallar un pull request que baja un umbral
(scripts/coverage_floor.py): los umbrales solo suben, junto con las pruebas
que los justifican.
Prueba de humo en vivo¶
scripts/smoke_matrix.py ejercita una instancia en marcha contra t.me real.
Además de los códigos de estado comprueba el contenido de las respuestas
principales (identidad del canal, publicaciones completas, paginación del lado
correcto, puntuaciones del feed): un cambio del marcado de t.me suele seguir
respondiendo 200, solo que con campos vacíos.
make up && make smoke
uv run --no-project python scripts/smoke_matrix.py --base-url https://api.example.com --preset auto
Solo usa la biblioteca estándar y solo páginas que también están grabadas, así que también pasa contra el t.me falso. La CI la ejecuta contra la imagen recién construida en cada pull request, cada despliegue la ejecuta en cada servidor y a través de la URL pública (véase Producción), y el workflow Nightly la ejecuta contra t.me real y contra producción.
Detrás de la validación de esquema de Cloudflare, las peticiones inválidas que
la matriz envía a propósito se bloquean en el edge (403, error 1020) antes de
llegar a la API. Ahí añade --edge-blocks-invalid: esas peticiones pasan
también con ese bloqueo, además de con el 422 de la API, y cualquier otro
403 sigue fallando. Lo usan las ejecuciones a través de la URL pública del
despliegue y de Nightly.
Comprobaciones estáticas¶
Además de las pruebas, cada commit pasa por comprobaciones que leen el código
sin ejecutarlo. make lint y make audit las ejecutan en local (make check
incluye ambas); make tools instala los binarios que uv sync no trae.
| Herramienta | Encuentra | Configuración |
|---|---|---|
| clippy, pedantic y una selección de lints restriction y nursery | en el crate nativo: unwrap(), panic!, todo!, dbg!, impresión, #[allow] sin motivo; indexación y cortes de cadenas sin comprobar en el parser |
[lints] en rust/telegram-me-native/Cargo.toml, clippy.toml |
| cargo-deny | avisos de RustSec, crates sin mantenimiento o retirados (yanked), licencias fuera de la lista permitida, dos versiones de un crate, registros y fuentes git desconocidos | rust/telegram-me-native/deny.toml |
| cargo-machete | dependencias de Rust que ningún código usa | |
mypy --strict, más warn_unreachable y doce códigos de error más |
código inalcanzable, overrides sin @override, nombres posiblemente indefinidos, condiciones siempre verdaderas, match no exhaustivo |
[tool.mypy] |
| ruff | bugbear, bandit, ... y ahora también argumentos sin usar, except ciego, fechas sin zona horaria, código comentado, miembros privados de otras clases, imports relativos y telegram_me_native._native |
[tool.ruff] |
| import-linter | un import contra las capas: api → container → services → cache → telegram → schemas → settings, y FastAPI o strawberry por debajo de api |
[tool.importlinter] |
| vulture | funciones, atributos y variables de src/ que nada en src/ usa (que solo lo usen las pruebas no basta) |
[tool.vulture], tests/vulture_whitelist.py |
uv audit |
vulnerabilidades conocidas de los paquetes de uv.lock |
|
| gitleaks | secretos en los archivos preparados (pre-commit) y en cada commit (CI), entre ellos la clave de bypass de Cloudflare | .gitleaks.toml |
| zizmor | riesgos de GitHub Actions: inyección en plantillas, credenciales persistidas, secretos heredados, Dependabot sin espera | .github/zizmor.yml |
| hadolint | errores del Dockerfile, hasta los de estilo | .hadolint.yaml |
| typos | palabras mal escritas en código y documentación (páginas en inglés y ruso; las españolas se omiten) | .typos.toml |
| shellcheck, actionlint | los scripts de despliegue y los workflows | |
| trivy | vulnerabilidades altas y críticas con corrección en la imagen construida | make image-scan |
| revisión de dependencias | dependencias vulnerables o con licencia GPL que añade un pull request | .github/workflows/dependency-review.yml |
| OpenSSF Scorecard | prácticas de cadena de suministro del repositorio, como alertas de code scanning | .github/workflows/scorecard.yml |
Un hallazgo se corrige, o se permite de la forma más acotada que admita la
herramienta y con el motivo al lado: #[expect(lint, reason = "...")] en Rust
(un #[allow] falla), # noqa: CODE - por qué en Python, una entrada con un
comentario en la configuración de la herramienta. Nada se desactiva para un
archivo entero para que una comprobación pase.
Trabajos de CI¶
| Workflow, trabajo | Cuándo | Duración | Detecta |
|---|---|---|---|
| CI: Rust | cada push | ~3 min | fmt, clippy, cargo-deny y cargo-machete (+15 s), pruebas de Rust (HTML hostil), umbral de cobertura de Rust |
| CI: Lint & types | cada push | ~4 min | ruff, mypy, import-linter, vulture, uv audit, typos, shellcheck, actionlint, zizmor, hadolint, gitleaks sobre el historial (en total +30 s) |
| CI: Tests | cada push | ~2 min | la batería anterior en Python 3.13 y 3.14, umbral de cobertura de Python |
| CI: Docker image smoke test | tras los tres anteriores | ~2 min | la imagen arranca endurecida, matriz de humo contra t.me real, parada ordenada |
| CI: Image vulnerabilities | tras la imagen | ~1 min | trivy: vulnerabilidades altas y críticas con corrección |
| CI: Integration | tras la imagen | ~2 min | pruebas de integración |
| CI: Deploy scripts | tras la imagen | ~2 min | pruebas de los scripts de despliegue |
| Guards | cada pull request, cambios de etiquetas | ~10 s | cambios incompatibles de la API, umbrales de cobertura rebajados |
| Dependency review | cada pull request | ~15 s | dependencias añadidas con vulnerabilidades conocidas o licencia GPL |
| Scorecard | pushes a main, los lunes |
~1 min | OpenSSF Scorecard (solo lee la rama por defecto) |
| Nightly | 03:23 UTC a diario | ~15 min | pruebas de humo contra t.me real y producción, 10 min de fuzzing, ejecuciones largas de propiedades; abre una issue con la etiqueta nightly |
Release (pushes a main) ejecuta CI y Guards antes de construir y desplegar
la imagen.