Las parejas se curan a mano, pero solo se pueden declarar cuando los dos lados están publicados: un hreflang a una URL programada apunta a un 404. Había cinco esperando, repartidas entre el 4 y el 27 de agosto, y la alternativa era acordarse de correr `aplicar` cinco veces en las fechas justas. Eso no es una solución: es una lista que se cumple a medias, y el hreflang que falta no da error en ninguna parte, así que nadie se entera. - hreflang_watch.py + timer diario a las 06:40 UTC (40 min tras la cola, antes de canibal-watch). Telegram SOLO si algo se ha abierto o ha fallado: 26 días de cada 31 no habrá nada, y si esos días avisara, el día que importa se leería igual que los otros. - `aplica()` extraída de cmd_aplicar: el automático escribe por el MISMO camino que el manual, no por una copia suya. - `comprueba` separa PENDIENTE de ROTO. Antes daba diez ✗ de 404 fijos por los espejos programados; diez rojos permanentes que no se pueden arreglar son un informe que se deja de leer. Verificado por el camino real, no en seco: se le quitó el bloque al lado ES de la pareja de Varginha (avería visible en la web pública, 0 hreflang), el vigilante la detectó, la escribió, la verificó contra las URLs exactas y avisó por Telegram. Restaurada y comprobada después. Y `verifica()` se ve fallar con una pareja cruzada a propósito, así que el ✅ no es vacuo. Nueve tests. Ensayado además arrancando el unit por systemd, que es donde los CLIs de node mueren con 127 si falta el bin de nvm en el PATH. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests de hreflang_watch.py y de la separación pendiente/roto.
|
|
|
|
Lo que se protege aquí no es «que no dé error»: es la disciplina de aviso. Un
|
|
vigilante que canta a diario cinco rojos que nadie puede arreglar se convierte
|
|
en ruido, y entonces el rojo de verdad tampoco se ve. Eso ya pasó con los
|
|
og_*/twitter_* de las páginas.
|
|
|
|
python3 -m pytest test_hreflang_watch.py -q
|
|
python3 test_hreflang_watch.py # sin pytest
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import seo_hreflang as H
|
|
import hreflang_watch as W
|
|
|
|
|
|
def _con_posts(en, es):
|
|
"""Sustituye la lectura de Ghost por corpus de mentira, por sitio."""
|
|
orig = H.trae
|
|
H.trae = lambda site, slugs: (en if site == "en" else es)
|
|
return orig
|
|
|
|
|
|
P = [("caso-en", "caso-es")]
|
|
|
|
|
|
def _post(slug, status, head=""):
|
|
return {"id": "i-" + slug, "slug": slug, "status": status,
|
|
"codeinjection_head": head}
|
|
|
|
|
|
# ─────────────── pendiente NO es roto (la cola de agosto) ───────────────
|
|
|
|
def test_un_espejo_programado_sale_como_pendiente_no_como_fallo():
|
|
orig = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "scheduled")})
|
|
try:
|
|
esp = H.pendientes(P)
|
|
finally:
|
|
H.trae = orig
|
|
assert esp == {("caso-en", "caso-es"): "published/scheduled"}, esp
|
|
|
|
|
|
def test_los_dos_publicados_no_estan_pendientes():
|
|
orig = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "published")})
|
|
try:
|
|
assert H.pendientes(P) == {}
|
|
finally:
|
|
H.trae = orig
|
|
|
|
|
|
def test_un_slug_que_no_existe_en_ghost_tambien_es_pendiente():
|
|
"""Si se renombra un slug en hreflang-parejas.md y no en Ghost, no hay que
|
|
tratarlo como una página rota: no hay nada que escribir todavía."""
|
|
orig = _con_posts({}, {"caso-es": _post("caso-es", "published")})
|
|
try:
|
|
assert H.pendientes(P) == {("caso-en", "caso-es"): "no encontrado en Ghost"}
|
|
finally:
|
|
H.trae = orig
|
|
|
|
|
|
# ─────────────── que un programado NUNCA se escriba ───────────────
|
|
|
|
def test_no_se_planifica_nada_mientras_el_espejo_este_programado():
|
|
"""Es la salvaguarda de fondo: un hreflang a una URL futura es un 404, y un
|
|
404 declarado como versión alternativa es peor que no declarar nada."""
|
|
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "scheduled")})
|
|
orig_p = H.lee_parejas
|
|
H.lee_parejas = lambda: P
|
|
try:
|
|
plan, avisos, _ = H.cambios()
|
|
finally:
|
|
H.trae, H.lee_parejas = orig_t, orig_p
|
|
assert plan == [], plan
|
|
assert any("404" in w for w in avisos), avisos
|
|
|
|
|
|
def test_en_cuanto_se_publica_el_espejo_aparece_el_plan():
|
|
"""La otra mitad: el vigilante no sirve si es tan prudente que nunca actúa."""
|
|
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "published")})
|
|
orig_p = H.lee_parejas
|
|
H.lee_parejas = lambda: P
|
|
try:
|
|
plan, _, _ = H.cambios()
|
|
finally:
|
|
H.trae, H.lee_parejas = orig_t, orig_p
|
|
assert len(plan) == 2, plan # escribe en los DOS lados
|
|
assert {s for s, _, _ in plan} == {"en", "es"}, plan
|
|
for _, _, nuevo in plan:
|
|
assert H.INICIO in nuevo and H.FIN in nuevo
|
|
assert 'hreflang="x-default"' in nuevo # sin él Google descarta el grupo
|
|
|
|
|
|
# ─────────────── silencio cuando no hay nada que contar ───────────────
|
|
|
|
def test_sin_nada_que_abrir_no_avisa_por_telegram():
|
|
"""26 días de cada 31 no habrá nada. Si esos días mandara mensaje, el día que
|
|
importa se leería igual que los otros."""
|
|
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "scheduled")})
|
|
orig_p, orig_tg = H.lee_parejas, W.telegram
|
|
H.lee_parejas = lambda: P
|
|
avisado = []
|
|
W.telegram = lambda t: avisado.append(t) or True
|
|
try:
|
|
rc = W.main()
|
|
finally:
|
|
H.trae, H.lee_parejas, W.telegram = orig_t, orig_p, orig_tg
|
|
assert rc == 0, rc
|
|
assert avisado == [], avisado
|
|
|
|
|
|
def test_el_aviso_dice_que_pareja_y_que_queda_pendiente():
|
|
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "published")})
|
|
orig_p, orig_tg, orig_a, orig_v = (H.lee_parejas, W.telegram,
|
|
H.aplica, W.verifica)
|
|
H.lee_parejas = lambda: P
|
|
avisado = []
|
|
W.telegram = lambda t: avisado.append(t) or True
|
|
H.aplica = lambda plan, log=print: ([(s, p["slug"]) for s, p, _ in plan],
|
|
[], "/tmp/copia.json")
|
|
W.verifica = lambda abiertas: []
|
|
try:
|
|
rc = W.main()
|
|
finally:
|
|
(H.trae, H.lee_parejas, W.telegram, H.aplica, W.verifica) = (
|
|
orig_t, orig_p, orig_tg, orig_a, orig_v)
|
|
assert rc == 0, rc
|
|
assert len(avisado) == 1, avisado
|
|
assert "caso-en" in avisado[0] and "caso-es" in avisado[0]
|
|
assert "verificado en la web pública" in avisado[0]
|
|
|
|
|
|
def test_si_ghost_guarda_pero_la_web_no_lo_emite_grita_y_sale_con_error():
|
|
"""El caso peligroso: Ghost dice OK y la página no lo tiene. Sin esto, el
|
|
vigilante daría por hecho algo que no está."""
|
|
orig_t = _con_posts({"caso-en": _post("caso-en", "published")},
|
|
{"caso-es": _post("caso-es", "published")})
|
|
orig_p, orig_tg, orig_a, orig_v = (H.lee_parejas, W.telegram,
|
|
H.aplica, W.verifica)
|
|
H.lee_parejas = lambda: P
|
|
avisado = []
|
|
W.telegram = lambda t: avisado.append(t) or True
|
|
H.aplica = lambda plan, log=print: ([(s, p["slug"]) for s, p, _ in plan],
|
|
[], "/tmp/copia.json")
|
|
W.verifica = lambda abiertas: ["caso-es: en dice None, debería ser X"]
|
|
try:
|
|
rc = W.main()
|
|
finally:
|
|
(H.trae, H.lee_parejas, W.telegram, H.aplica, W.verifica) = (
|
|
orig_t, orig_p, orig_tg, orig_a, orig_v)
|
|
assert rc == 1, "un fallo silencioso deja exit 0 y systemd lo da por bueno"
|
|
assert "🚨" in avisado[0] and "MAL en la web pública" in avisado[0], avisado
|
|
|
|
|
|
if __name__ == "__main__":
|
|
fallos = 0
|
|
for nombre, fn in sorted(globals().items()):
|
|
if not nombre.startswith("test_"):
|
|
continue
|
|
try:
|
|
fn()
|
|
print(f" ✓ {nombre}")
|
|
except AssertionError as exc:
|
|
fallos += 1
|
|
print(f" ✗ {nombre}: {exc}")
|
|
print(f"\n{'✓ todo pasa' if not fallos else f'✗ {fallos} fallo(s)'}")
|
|
sys.exit(1 if fallos else 0)
|