hreflang: aplicarlo solo cuando el espejo publica, sin que nadie se acuerde
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>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=hreflang — declara la pareja ES↔EN en cuanto el espejo se publica
|
||||
Documentation=file:///home/chemavx/seo-tools/hreflang_watch.py
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=chemavx
|
||||
WorkingDirectory=/home/chemavx/seo-tools
|
||||
Environment=HOME=/home/chemavx
|
||||
Environment=KUBECONFIG=/home/chemavx/.kube/config
|
||||
# El bin de nvm es obligatorio: ghst-en/ghst-es son CLIs de node y sin él el
|
||||
# unit muere con 127 sin decir por qué (cicatriz compartida con los demás).
|
||||
Environment=PATH=/home/chemavx/.local/bin:/home/chemavx/.nvm/versions/node/v24.14.1/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStart=/usr/bin/python3 /home/chemavx/seo-tools/hreflang_watch.py
|
||||
TimeoutStartSec=900
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=hreflang, a diario a las 06:40 UTC (40 min después de que publique la cola)
|
||||
|
||||
[Timer]
|
||||
# La cola publica a las 06:00 UTC. 06:40 da margen a que Ghost haya publicado y
|
||||
# la página esté servida, y va antes que canibal-watch (07:00) para que el resto
|
||||
# del día ya vea el hreflang puesto.
|
||||
OnCalendar=*-*-* 06:40:00 UTC
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hreflang-watch — pone el hreflang en cuanto el espejo se publica.
|
||||
|
||||
Por qué existe. Las parejas se curan a mano en hreflang-parejas.md, pero solo se
|
||||
pueden DECLARAR cuando los dos lados están publicados: un hreflang a una URL
|
||||
programada apunta a un 404. El 2026-07-30 había cinco parejas esperando a que
|
||||
publicase el espejo español, repartidas entre el 4 y el 27 de agosto:
|
||||
|
||||
04-ago oleada belga 11-ago proyecto Libro Azul
|
||||
24-ago Colares 25-ago JAL 1628 27-ago Teherán
|
||||
|
||||
La alternativa a esto era que Jose se acordase de correr `seo_hreflang.py
|
||||
aplicar` cinco veces en agosto, cada una el día justo. Eso no es una solución,
|
||||
es una lista de tareas que se cumple a medias: el hreflang se queda a medio
|
||||
poner y encima nadie se entera, porque el que falta no da error en ningún sitio.
|
||||
|
||||
Qué hace. Una vez al día pregunta si alguna pareja ha pasado a publicada por los
|
||||
dos lados, y si es así la escribe y la verifica en la web pública. Escribe por
|
||||
`seo_hreflang.aplica()`, el MISMO camino que el comando manual — no una copia.
|
||||
|
||||
Disciplina de aviso (la de la casa): Telegram solo si ha PASADO algo. Nada de
|
||||
recordar a diario las que faltan; el silencio significa «ninguna se ha abierto
|
||||
hoy», y ese es el estado normal 26 días de cada 31. Si el silencio fuera el
|
||||
aviso, el aviso de verdad no se distinguiría.
|
||||
|
||||
python3 hreflang_watch.py --seco # dice qué haría, no escribe ni avisa
|
||||
python3 hreflang_watch.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import seo_hreflang as H # noqa: E402
|
||||
|
||||
TG_SECRET = ("monitoring", "grafana-telegram-infisical")
|
||||
LOG_DIR = Path.home() / ".local/state/hreflang-watch"
|
||||
|
||||
|
||||
def sh(cmd):
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def telegram(texto):
|
||||
"""Mismo camino que canibal-watch e indexnow-watch: el token sale del secret
|
||||
que gestiona Infisical, nunca de disco."""
|
||||
t = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} "
|
||||
f"-o jsonpath='{{.data.TELEGRAM_BOT_TOKEN}}'")
|
||||
c = sh(f"kubectl get secret -n {TG_SECRET[0]} {TG_SECRET[1]} "
|
||||
f"-o jsonpath='{{.data.TELEGRAM_CHAT_ID}}'")
|
||||
if not t.stdout or not c.stdout:
|
||||
print("✗ no se pudo leer el secret de Telegram", file=sys.stderr)
|
||||
return False
|
||||
token = base64.b64decode(t.stdout).decode()
|
||||
chat = base64.b64decode(c.stdout).decode()
|
||||
data = urllib.parse.urlencode({"chat_id": chat, "text": texto[:3900]}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://api.telegram.org/bot{token}/sendMessage", data=data)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read()).get("ok", False)
|
||||
|
||||
|
||||
def verifica(parejas_hechas):
|
||||
"""Comprueba en la web PÚBLICA solo las parejas que acabamos de escribir.
|
||||
|
||||
Que Ghost devuelva 200 al guardar no significa que la página lo emita: el
|
||||
codeinjection_head podría no llegar a renderizarse. Y se comprueban las URLs
|
||||
EXACTAS, no «que haya hreflang»: un par cruzado también tendría dos.
|
||||
"""
|
||||
malos = []
|
||||
for slug_en, slug_es in sorted(parejas_hechas):
|
||||
esperado = {"en": f"{H.SITIOS['en']['base']}/{slug_en}/",
|
||||
"es": f"{H.SITIOS['es']['base']}/{slug_es}/"}
|
||||
esperado["x-default"] = esperado["en"]
|
||||
for site, slug in (("en", slug_en), ("es", slug_es)):
|
||||
url = f"{H.SITIOS[site]['base']}/{slug}/"
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "hreflang-watch/1.0"})
|
||||
html = urllib.request.urlopen(req, timeout=30).read().decode("utf8", "ignore")
|
||||
except Exception as exc:
|
||||
malos.append(f"{slug}: no se pudo leer ({exc})")
|
||||
continue
|
||||
enc = dict(H.re.findall(
|
||||
r'<link[^>]+hreflang="([^"]+)"[^>]+href="([^"]+)"', html))
|
||||
for k, v in esperado.items():
|
||||
if enc.get(k) != v:
|
||||
malos.append(f"{slug}: {k} dice {enc.get(k)}, debería ser {v}")
|
||||
return malos
|
||||
|
||||
|
||||
def main():
|
||||
seco = "--seco" in sys.argv
|
||||
plan, avisos, parejas = H.cambios()
|
||||
|
||||
espera = H.pendientes(parejas)
|
||||
for (a, b), estado in sorted(espera.items()):
|
||||
print(f" … pendiente ({estado}): {a[:52]}")
|
||||
|
||||
if not plan:
|
||||
print(f"nada que abrir hoy · {len(parejas) - len(espera)} vivas, "
|
||||
f"{len(espera)} esperando al espejo")
|
||||
return 0
|
||||
|
||||
print(f"\n{len(plan)} posts a escribir:")
|
||||
for site, p, _ in plan:
|
||||
print(f" [{site}] {p['slug'][:56]}")
|
||||
if seco:
|
||||
print("\n(--seco: no se ha escrito nada)")
|
||||
return 0
|
||||
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
hechos, fallos, copia = H.aplica(plan)
|
||||
|
||||
# De los slugs escritos, deducir qué PAREJAS se han abierto, para verificar
|
||||
# solo esas: el resto ya se verificó el día que se pusieron.
|
||||
tocados = {s for _, s in hechos}
|
||||
abiertas = {(a, b) for a, b in parejas if a in tocados or b in tocados}
|
||||
malos = verifica(abiertas) if hechos else []
|
||||
|
||||
lineas = [f"🌐 hreflang: {len(abiertas)} pareja(s) abiertas"]
|
||||
for a, b in sorted(abiertas):
|
||||
lineas.append(f"• {a[:44]} ↔ {b[:44]}")
|
||||
if fallos:
|
||||
lineas.append(f"\n🚨 {len(fallos)} no se pudieron escribir:")
|
||||
lineas += [f"• [{s}] {sl}" for s, sl in fallos]
|
||||
if malos:
|
||||
lineas.append("\n🚨 escrito en Ghost pero MAL en la web pública:")
|
||||
lineas += [f"• {m}" for m in malos]
|
||||
if not fallos and not malos:
|
||||
lineas.append("\n✅ verificado en la web pública, URLs exactas")
|
||||
if espera:
|
||||
lineas.append(f"\n… quedan {len(espera)} esperando a que publique el espejo")
|
||||
lineas.append(f"\ncopia previa: {copia}")
|
||||
|
||||
texto = "\n".join(lineas)
|
||||
print("\n" + texto)
|
||||
if not telegram(texto):
|
||||
print("✗ no se pudo avisar por Telegram", file=sys.stderr)
|
||||
return 1
|
||||
return 1 if (fallos or malos) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+53
-14
@@ -231,13 +231,14 @@ def cmd_plan(a):
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_aplicar(a):
|
||||
plan, avisos, _ = cambios()
|
||||
for w in avisos:
|
||||
print(f" ⚠ {w}")
|
||||
if not plan:
|
||||
print("nada que hacer: ya está todo puesto")
|
||||
return 0
|
||||
def aplica(plan, log=print):
|
||||
"""Escribe el plan en Ghost. Devuelve (hechos, fallos, ruta_de_la_copia).
|
||||
|
||||
Vive fuera de cmd_aplicar para que el vigilante automático (hreflang_watch)
|
||||
escriba por ESTE camino y no por una copia suya: si el manual y el
|
||||
automático no comparten la escritura, el ensayo de uno no dice nada del
|
||||
otro.
|
||||
"""
|
||||
os.makedirs(BDIR, exist_ok=True)
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bpath = os.path.join(BDIR, f"hreflang-pristine-{ts}.json")
|
||||
@@ -245,8 +246,8 @@ def cmd_aplicar(a):
|
||||
json.dump([{"site": s, "id": p["id"], "slug": p["slug"],
|
||||
"codeinjection_head": p.get("codeinjection_head")}
|
||||
for s, p, _ in plan], f, ensure_ascii=False, indent=1)
|
||||
print(f"copia previa: {bpath}\n")
|
||||
fallos = 0
|
||||
log(f"copia previa: {bpath}\n")
|
||||
hechos, fallos = [], []
|
||||
for site, p, nuevo in plan:
|
||||
fd, ppath = tempfile.mkstemp(suffix=".json")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
@@ -254,20 +255,56 @@ def cmd_aplicar(a):
|
||||
r = sh(f'{SITIOS[site]["cli"]} post update {p["id"]} --from-json "{ppath}"')
|
||||
os.unlink(ppath)
|
||||
ok = "Slug:" in r.stdout
|
||||
fallos += 0 if ok else 1
|
||||
print(f" {'✓' if ok else '✗'} [{site}] {p['slug'][:56]}")
|
||||
(hechos if ok else fallos).append((site, p["slug"]))
|
||||
log(f" {'✓' if ok else '✗'} [{site}] {p['slug'][:56]}")
|
||||
if not ok:
|
||||
print(f" {(r.stdout + r.stderr)[:200]}")
|
||||
print(f"\n{len(plan) - fallos}/{len(plan)} aplicados")
|
||||
log(f" {(r.stdout + r.stderr)[:200]}")
|
||||
return hechos, fallos, bpath
|
||||
|
||||
|
||||
def cmd_aplicar(a):
|
||||
plan, avisos, _ = cambios()
|
||||
for w in avisos:
|
||||
print(f" ⚠ {w}")
|
||||
if not plan:
|
||||
print("nada que hacer: ya está todo puesto")
|
||||
return 0
|
||||
hechos, fallos, _ = aplica(plan)
|
||||
print(f"\n{len(hechos)}/{len(plan)} aplicados")
|
||||
return 1 if fallos else 0
|
||||
|
||||
|
||||
def pendientes(parejas):
|
||||
"""Parejas en las que algún lado no está publicado todavía.
|
||||
|
||||
Se separan de los fallos porque NO son un fallo: la cola de agosto tiene
|
||||
cinco espejos programados, y comprobarlos daba diez ✗ de 404 en cada
|
||||
ejecución. Un informe con diez rojos permanentes que no se pueden arreglar
|
||||
es un informe que se deja de leer, y entonces el rojo de verdad tampoco se
|
||||
ve. Mismo criterio que `cambios()`: aquí solo se juzga lo que ya vive.
|
||||
"""
|
||||
en = trae("en", {a for a, _ in parejas})
|
||||
es = trae("es", {b for _, b in parejas})
|
||||
out = {}
|
||||
for a, b in parejas:
|
||||
pa, pb = en.get(a), es.get(b)
|
||||
if not pa or not pb:
|
||||
out[(a, b)] = "no encontrado en Ghost"
|
||||
elif pa["status"] != "published" or pb["status"] != "published":
|
||||
out[(a, b)] = f"{pa['status']}/{pb['status']}"
|
||||
return out
|
||||
|
||||
|
||||
def cmd_comprueba(a):
|
||||
"""La verdad está en la página pública: que Ghost lo guarde no basta."""
|
||||
import urllib.request
|
||||
parejas = lee_parejas()
|
||||
espera = pendientes(parejas)
|
||||
malos = 0
|
||||
for slug_en, slug_es in parejas:
|
||||
if (slug_en, slug_es) in espera:
|
||||
print(f" … [pendiente {espera[(slug_en, slug_es)]}] {slug_en[:46]}")
|
||||
continue
|
||||
for site, slug in (("en", slug_en), ("es", slug_es)):
|
||||
url = f"{SITIOS[site]['base']}/{slug}/"
|
||||
try:
|
||||
@@ -290,7 +327,9 @@ def cmd_comprueba(a):
|
||||
print(f" {'✓' if not mal else '✗'} [{site}] {slug[:52]}")
|
||||
for k, (tiene, debe) in mal.items():
|
||||
print(f" {k}: dice {tiene} — debería ser {debe}")
|
||||
print(f"\n{'✓ todas las páginas declaran las dos versiones' if not malos else f'✗ {malos} con problema'}")
|
||||
vivas = len(parejas) - len(espera)
|
||||
print(f"\n{f'✓ las {vivas} parejas vivas declaran las dos versiones' if not malos else f'✗ {malos} con problema'}"
|
||||
+ (f" · {len(espera)} pendientes de que publique el espejo" if espera else ""))
|
||||
return 1 if malos else 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user