Files
ChemaVXandClaude Opus 5 d2882c0fdd estado: comparar twitter:site contra el handle real, no contra @ghost
El chequeo tenía una lista negra de un solo elemento (el default de Ghost)
y por eso se le colaba el caso peor: el sitio ES apuntaba a
@ZonaDeExclusion, un handle que no existe. Un handle ajeno o abandonado es
más grave que el default, porque cualquiera puede reclamarlo y quedarse con
la atribución de las tarjetas de X.

Hay UNA sola cuenta para los dos blogs (@ExclusionZoneEN), así que se
declara como constante y se compara contra ella. También avisa si el meta
desaparece del todo: vaciar el ajuste en Ghost lo quita, y el silencio no
es lo mismo que estar bien.

Cuatro tests, y verificado que el chequeo se ve fallar con el handle
esperado falseado — no es una comparación vacua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:57:54 +00:00

254 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Tests de seo_estado.py — los puntos ciegos del vigilante.
Cada test de aquí es la cicatriz de un fallo REAL de la semana del 2026-07-29.
Se ejercitan las dos cosas que hacen creíble a un vigilante:
1. que DETECTA la avería (no basta con que no dé error)
2. que un chequeo roto no deja el informe limpio por accidente
python3 -m pytest test_seo_estado.py -q
python3 test_seo_estado.py # sin pytest
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import seo_estado as E
def _tags_falsos(tags):
"""Sustituye la llamada al CLI por una lista de tags de mentira."""
payload = json.dumps({"tags": tags})
return lambda cmd, timeout=180: type(
"R", (), {"returncode": 0, "stdout": payload, "stderr": ""})()
# ─────────────────────── tags: el bug que vivió 3 semanas ───────────────────────
def test_detecta_el_duplicado_de_ghost():
"""`Casos Militares` + `casos-militares` → Ghost crea `casos-militares-2`.
Es la firma exacta de researchowl mandando el slug en el campo `name`."""
orig = E.sh
E.sh = _tags_falsos([
{"slug": "casos-militares", "name": "Casos Militares", "visibility": "public"},
{"slug": "casos-militares-2", "name": "casos-militares", "visibility": "public"},
])
try:
salida = E.check_tags("es", E.SITES["es"], [
{"slug": "p", "status": "published",
"tags": [{"slug": "casos-militares-2", "visibility": "public"}]}])
finally:
E.sh = orig
assert any("DUPLICADOS" in l for l in salida), salida
def test_no_inventa_duplicados_donde_no_los_hay():
orig = E.sh
E.sh = _tags_falsos([
{"slug": "casos-militares", "name": "Casos Militares", "visibility": "public"},
{"slug": "casos-espana", "name": "Casos España", "visibility": "public"},
])
try:
salida = E.check_tags("es", E.SITES["es"], [
{"slug": "p", "status": "published", "tags": [
{"slug": "casos-militares", "visibility": "public"},
{"slug": "casos-espana", "visibility": "public"}]}])
finally:
E.sh = orig
assert not any("DUPLICADOS" in l for l in salida), salida
def test_ve_los_programados_sin_categoria():
"""Los 9 de agosto salían con solo el tag interno #agosto-2026 y se habrían
publicado sin ninguna categoría. `check_rules` de seo_watch los salta."""
orig = E.sh
E.sh = _tags_falsos([{"slug": "uap", "name": "UAP", "visibility": "public"}])
try:
salida = E.check_tags("es", E.SITES["es"], [
{"slug": "agosto-1", "status": "scheduled",
"tags": [{"slug": "hash-agosto-2026", "visibility": "internal"}]},
{"slug": "vivo", "status": "published",
"tags": [{"slug": "uap", "visibility": "public"}]}])
finally:
E.sh = orig
assert any("scheduled SIN CATEGORÍA" in l for l in salida), salida
# ────────────────────────────── robots: la deriva ──────────────────────────────
ROBOTS_EN = """
User-agent: *
Content-Signal: search=yes,ai-train=no,use=reference
Allow: /
User-agent: GPTBot
User-agent: CCBot
Disallow: /
User-agent: *
Sitemap: https://x/sitemap.xml
Disallow: /ghost/
"""
def test_parser_de_robots_agrupa_agentes_consecutivos():
p = E.politica_robots(ROBOTS_EN)
assert p["bloqueados"] == ["CCBot", "GPTBot"], p
assert p["content_signal"] == "search=yes,ai-train=no,use=reference", p
def test_disallow_parcial_no_cuenta_como_bloqueo():
"""`Disallow: /ghost/` NO es bloquear el sitio. Confundirlos daría por
bloqueado a todo bot con una regla de directorio."""
assert E.politica_robots(ROBOTS_EN)["bloqueados"] == ["CCBot", "GPTBot"]
def test_comentarios_no_confunden_al_parser():
txt = "User-agent: GPTBot # el de OpenAI\nDisallow: / # todo\n"
assert E.politica_robots(txt)["bloqueados"] == ["GPTBot"]
# ───────────────────── resiliencia: un chequeo roto se OYE ─────────────────────
def test_un_chequeo_que_revienta_no_deja_el_informe_limpio():
"""Lo peligroso de un vigilante no es que falle: es que falle y parezca que
todo está bien."""
orig = E.CHEQUEOS
def explota(s, c, p):
raise RuntimeError("boom")
E.CHEQUEOS = (("explosivo", explota),)
try:
r = E.revisa("en", [])
finally:
E.CHEQUEOS = orig
assert "explosivo" in r and "ha fallado" in r["explosivo"][0], r
# ───────────────── páginas: no fabricar hallazgos inatendibles ─────────────────
def test_las_reglas_de_post_que_no_aplican_a_paginas_quedan_fuera():
"""og_*/twitter_* vacíos en una PÁGINA no son un fallo: Ghost los deriva de
las metas al renderizar (verificado sobre el HTML público del hub ES)."""
for regla in ("og_title.empty", "twitter_description.empty",
"feature_image.missing", "internal_links.too_few"):
assert E._fuera_de_paginas(regla), regla
for regla in ("meta_title.missing", "meta_description.too_long"):
assert not E._fuera_de_paginas(regla), regla
# ───────── archivos de tag: lo que el lector ve y lo que ve Google ─────────
def test_detecta_el_nombre_de_tag_que_es_el_slug():
"""El H1 del archivo sale del `name`. En el EN los siete se llamaban como
su slug: /tag/military-cases/ enseñaba «military-cases» al lector."""
orig = E.sh
E.sh = _tags_falsos([
{"slug": "military-cases", "name": "military-cases", "visibility": "public"},
])
try:
salida = E.check_tags("en", E.SITES["en"], [
{"slug": "p", "status": "published",
"tags": [{"slug": "military-cases", "visibility": "public"}]}])
finally:
E.sh = orig
assert any("NOMBRE es el slug" in l for l in salida), salida
def test_un_acronimo_de_una_palabra_no_cuenta_como_slug_crudo():
"""`uap` llamándose «uap» no es el mismo problema que «military-cases»:
sin guion no se lee como slug. Exigir el guion evita el falso positivo."""
orig = E.sh
E.sh = _tags_falsos([{"slug": "uap", "name": "uap", "visibility": "public"}])
try:
salida = E.check_tags("en", E.SITES["en"], [
{"slug": "p", "status": "published",
"tags": [{"slug": "uap", "visibility": "public"}]}])
finally:
E.sh = orig
assert not any("NOMBRE es el slug" in l for l in salida), salida
def test_detecta_dos_archivos_de_tag_con_el_mismo_title():
"""Salió al renombrar: `uap` e `investigation` compartían meta_title, o sea
dos páginas indexadas compitiendo con el mismo titular."""
orig = E.sh
E.sh = _tags_falsos([
{"slug": "uap", "name": "UAP", "visibility": "public",
"meta_title": "UAP Investigation — The Exclusion Zone"},
{"slug": "investigation", "name": "Investigation", "visibility": "public",
"meta_title": "UAP Investigation — The Exclusion Zone"},
])
try:
salida = E.check_tags("en", E.SITES["en"], [
{"slug": "p", "status": "published", "tags": [
{"slug": "uap", "visibility": "public"},
{"slug": "investigation", "visibility": "public"}]}])
finally:
E.sh = orig
assert any("mismo <title>" in l for l in salida), salida
# ───────── twitter:site: el handle bueno, no «cualquiera menos @ghost» ─────────
def _head_falso(html):
"""Sustituye la descarga del HTML público por una cabecera de mentira."""
return lambda url: html
POST_MIN = [{"slug": "p", "status": "published"}]
def _check_head_con(html):
orig = E.trae
E.trae = _head_falso(html)
try:
return E.check_head("es", E.SITES["es"], POST_MIN)
finally:
E.trae = orig
def test_un_handle_ajeno_no_pasa_por_bueno():
"""El chequeo solo miraba el default de Ghost, así que se le colaba lo PEOR
de los dos: el ES apuntaba a @ZonaDeExclusion, un handle que no existe. Un
handle abandonado lo puede reclamar cualquiera y quedarse la atribución."""
salida = _check_head_con('<meta name="twitter:site" content="@ZonaDeExclusion">')
assert any("@ZonaDeExclusion" in l and "debería ser" in l for l in salida), salida
def test_el_handle_bueno_no_genera_hallazgo():
salida = _check_head_con(f'<meta name="twitter:site" content="{E.HANDLE_X}">')
assert not any("twitter:site" in l for l in salida), salida
def test_el_default_de_ghost_sigue_diciendo_que_es_el_default():
"""Regresión: al pasar a comparar contra el handle real, el caso @ghost tiene
que seguir explicando POR QUÉ está mal, no solo que no coincide."""
salida = _check_head_con('<meta name="twitter:site" content="@ghost">')
assert any("default de Ghost" in l for l in salida), salida
def test_sin_twitter_site_tambien_avisa():
"""Vaciar el ajuste en Ghost quita el meta entero. Silencio no es OK."""
salida = _check_head_con("<title>nada</title>")
assert any("no hay twitter:site" in l for l in salida), salida
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)