03-oct: superguardado automático

This commit is contained in:
minguezsanzjuanjose
2026-10-03 17:13:39 +02:00
parent f10ce5bef4
commit fdd4ea877a
31 changed files with 2229 additions and 4 deletions

13
tests/conftest.py Normal file
View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""conftest.py — Deja importable el paquete `monitor_estado` en los tests.
Anade INFRAESTRUCTURA/scripts al sys.path para poder hacer
`import monitor_estado.<modulo>` sin instalar el paquete.
"""
import sys
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))

73
tests/test_archivo.py Normal file
View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.archivo` — ficheros del dia, reescritura y poda (RF-5,7,9,10,18)."""
from datetime import date, timedelta
import monitor_estado.archivo as archivo
def _render(m):
return f"- **{m['id']}**: {m['t']}"
def test_archivo_reescribe_sin_duplicados(tmp_path, monkeypatch):
"""El fichero del dia contiene todo lo de hoy y nada duplicado (RF-9)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
hoy = date(2026, 10, 3)
p = archivo.escribe_dia(
"telegram/test", hoy,
[{"id": 1, "t": "a"}, {"id": 2, "t": "b"}, {"id": 2, "t": "b"}],
id_fn=lambda m: m["id"], render_fn=_render,
)
contenido = p.read_text(encoding="utf-8")
assert contenido.count("- **1**") == 1
assert contenido.count("- **2**") == 1
# Nuevo ciclo con lo del dia completo: el fichero se reescribe con el estado actual.
archivo.escribe_dia(
"telegram/test", hoy,
[{"id": 1, "t": "a"}, {"id": 2, "t": "b"}, {"id": 3, "t": "c"}],
id_fn=lambda m: m["id"], render_fn=_render,
)
contenido = p.read_text(encoding="utf-8")
assert "- **3**" in contenido
assert contenido.count("- **1**") == 1
def test_archivo_poda_30_dias_y_conserva_recientes(tmp_path, monkeypatch):
"""Se borran los ficheros de mas de 30 dias y se conservan los recientes (RF-10)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
hoy = date(2026, 10, 3)
reciente = tmp_path / "telegram" / "test"
reciente.mkdir(parents=True)
f_hoy = reciente / f"{hoy.isoformat()}.md"
f_10 = reciente / f"{(hoy - timedelta(days=10)).isoformat()}.md"
f_40 = reciente / f"{(hoy - timedelta(days=40)).isoformat()}.md"
for f in (f_hoy, f_10, f_40):
f.write_text("x", encoding="utf-8")
borrados = archivo.poda(retencion_dias=30, hoy=hoy)
assert f_hoy.exists() and f_10.exists()
assert not f_40.exists()
assert f_40 in borrados
def test_escribe_resumen(tmp_path, monkeypatch):
"""El resumen se guarda en md y json consultables (RF-18)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
resumen = {"resumen_md": "Todo ok", "requiere_atencion": [], "errores": []}
archivo.escribe_resumen(resumen)
assert (tmp_path / "resumen_actual.md").read_text(encoding="utf-8") == "Todo ok"
assert '"resumen_md": "Todo ok"' in (tmp_path / "resumen_actual.json").read_text(encoding="utf-8")
def test_escribe_dia_de_fuente_sin_novedad(tmp_path, monkeypatch):
"""Una fuente sin elementos genera un fichero vacio (no falla) (RF-5)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
p = archivo.escribe_dia(
"correo", date(2026, 10, 3), [], id_fn=lambda m: m, render_fn=lambda m: str(m)
)
assert p.exists()

32
tests/test_aviso.py Normal file
View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.aviso` — voz solo si requiere atencion (RF-14, RF-15)."""
import monitor_estado.aviso as aviso
def test_aviso_dispara_con_atencion():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [{"fuente": "jira", "texto": "caido"}]}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is True
assert llamadas == ["fin"]
def test_ciclo_sin_novedad_no_avisa():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [], "resumen_md": "Sin novedades."}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is False
assert llamadas == []
def test_aviso_dispara_con_fallo_ia():
llamadas = []
r = {"fallo_ia": True, "requiere_atencion": []}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is True
assert llamadas == ["error"]
def test_aviso_desactivado_por_config():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [{"texto": "x"}]}
assert aviso.avisar(r, cfg={"aviso": {"voz": False}}, _runner=lambda t: llamadas.append(t)) is False
assert llamadas == []

81
tests/test_ciclo.py Normal file
View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.ciclo` — aislamiento de fuentes y ciclo completo (RF-6, RF-9)."""
from datetime import date
import monitor_estado.archivo as archivo
import monitor_estado.ciclo as ciclo
import monitor_estado.vistos as vistos
class _EmptyJira:
def search(self, jql, fields=None, max_results=50):
return {"issues": []}
def _preparar_tmp(tmp_path, monkeypatch):
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path / "visto")
def test_aislamiento_fuente_fallida_continua(tmp_path, monkeypatch):
"""Si todas las fuentes fallan, el ciclo termina y registra los errores (RF-6)."""
_preparar_tmp(tmp_path, monkeypatch)
def bad_tg(cfg, hoy):
raise RuntimeError("telegram caido")
def bad_correo(horas=24, limite=50):
raise RuntimeError("gmail caido")
class BadJira:
def search(self, *a, **k):
raise RuntimeError("jira caido")
res = ciclo.ejecutar(
cfg={"retencion_dias": 30, "correo": {"horas": 24}},
deps={
"telegram_recoger": bad_tg,
"correo_recoger": bad_correo,
"jira_cliente": BadJira(),
"ia": lambda p: '{"resumen_md": "x", "requiere_atencion": []}',
"avisar": lambda r, cfg=None, _runner=None: False,
},
)
assert len(res["errores"]) == 3
assert res["fallo_ia"] is False
assert (tmp_path / "ultimo_ciclo.json").exists()
def test_ciclo_archiva_y_avisa(tmp_path, monkeypatch):
"""Un ciclo con novedad archiva el dia y avisa si hay atencion (RF-9, RF-14)."""
_preparar_tmp(tmp_path, monkeypatch)
emitidos = []
def tg(cfg, hoy):
return ({"oscar_rodriguez": [{"id": 1, "fecha": "2026-10-03T09:00:00", "quien": "Oscar", "texto": "incidencia"}]}, [])
def correo(horas=24, limite=50):
return []
def ia(prompt):
return '{"resumen_md": "hay incidencia", "requiere_atencion": [{"fuente": "telegram", "texto": "incidencia"}]}'
def avisar(res, cfg=None, _runner=None):
emitidos.append(res.get("requiere_atencion"))
return True
res = ciclo.ejecutar(
cfg={"retencion_dias": 30, "correo": {"horas": 24}},
hoy=date(2026, 10, 3),
deps={
"telegram_recoger": tg,
"correo_recoger": correo,
"jira_cliente": _EmptyJira(),
"ia": ia,
"avisar": avisar,
},
)
assert (tmp_path / "telegram" / "oscar_rodriguez" / "2026-10-03.md").exists()
assert res["aviso_emitido"] is True
assert emitidos and emitidos[0]

74
tests/test_correo.py Normal file
View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.correo` — ventana 24h y solo lectura (RF-2, RF-25)."""
import monitor_estado.fuentes.correo as correo
class _Req:
def __init__(self, resp):
self._resp = resp
def execute(self):
return self._resp
class _Messages:
def __init__(self):
self.queries = []
self.gets = []
self.modificados = []
def list(self, userId, q, maxResults):
self.queries.append(q)
return _Req({"messages": [{"id": "m1"}, {"id": "m2"}]})
def get(self, userId, id, format, metadataHeaders):
self.gets.append(id)
return _Req({
"snippet": f"resumen de {id}",
"payload": {"headers": [
{"name": "Subject", "value": f"Asunto {id}"},
{"name": "From", "value": "Remitente <r@b.com>"},
{"name": "Date", "value": "Sat, 3 Oct 2026 09:00:00 +0200"},
]},
})
def modify(self, **kwargs): # no debe llamarse nunca (RF-25)
self.modificados.append(kwargs)
return _Req({})
class _Users:
def __init__(self):
self._m = _Messages()
def messages(self):
return self._m
class FakeGmail:
def __init__(self):
self._u = _Users()
def users(self):
return self._u
def test_correo_ventana_24h():
gmail = FakeGmail()
correos = correo.recoger_recientes(gmail, horas=24)
assert len(correos) == 2
assert correos[0]["id"] == "m1"
assert correos[0]["asunto"] == "Asunto m1"
assert "newer_than:1d" in gmail.users().messages().queries[0]
def test_correo_no_modifica_estado():
gmail = FakeGmail()
correo.recoger_recientes(gmail, horas=24)
assert gmail.users().messages().modificados == [] # no marca leido (RF-25)
def test_render_correo():
txt = correo.render_correo({"fecha": "Sat, 3 Oct 2026 09:00:00 +0200", "de": "a@b.com", "asunto": "Hola", "snippet": "cuerpo"})
assert "a@b.com" in txt and "Hola" in txt

58
tests/test_jira.py Normal file
View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.jira` — DS nuevos y sin asignar, solo GET (RF-3, RF-4, RF-16)."""
import monitor_estado.fuentes.jira as jira
def _issue(key, summary, status="Pendiente", created="2026-10-03T08:00:00.000+0200"):
return {"key": key, "fields": {
"summary": summary,
"status": {"name": status},
"created": created,
"priority": {"name": "Media"},
}}
class FakeJira:
"""Solo implementa `search` (lectura). Si el codigo intentara escribir, fallaria."""
def __init__(self):
self.calls = []
def search(self, jql, fields=None, max_results=50):
self.calls.append(jql)
if "assignee = EMPTY" in jql:
issues = [_issue("DS-100", "Ticket sin asignar")]
elif "created >=" in jql:
issues = [_issue("DS-200", "Ticket nuevo")]
else:
issues = []
return {"issues": issues}
def test_jira_jql_ds_sin_asignar():
fake = FakeJira()
out = jira.ds_sin_asignar(fake)
assert any("assignee = EMPTY" in c and "project = DS" in c for c in fake.calls)
assert out[0]["key"] == "DS-100"
assert out[0]["estado"] == "Pendiente"
def test_jira_ds_nuevos_ventana():
fake = FakeJira()
out = jira.ds_nuevos(fake, "2026-10-03 00:00")
assert "created >=" in fake.calls[-1]
assert out[0]["key"] == "DS-200"
def test_jira_solo_lectura():
"""El cliente usado solo expone `search` (no hay escritura) — RF-16."""
fake = FakeJira()
jira.ds_sin_asignar(fake)
jira.ds_nuevos(fake, "2026-10-03 00:00")
assert not hasattr(fake, "create_issue") and not hasattr(fake, "post")
def test_render_jira():
txt = jira.render_jira({"key": "DS-1", "resumen": "Algo", "estado": "Pendiente", "url": "u"})
assert "DS-1" in txt and "Algo" in txt and "Pendiente" in txt

40
tests/test_lock.py Normal file
View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.lock` — instancia unica (RF-22)."""
import json
import monitor_estado.lock as lock
def test_lock_impide_segunda_instancia(tmp_path, monkeypatch):
"""Una segunda adquisicion con la primera viva debe fallar (RF-22)."""
monkeypatch.setattr(lock, "LOCK_PATH", tmp_path / ".lock")
assert lock.adquirir() is True
# Misma PID viva -> la segunda instancia no arranca.
assert lock.adquirir() is False
lock.liberar()
assert not (tmp_path / ".lock").exists()
assert lock.adquirir() is True
lock.liberar()
def test_lock_recupera_lock_caducado(tmp_path, monkeypatch):
"""Un lock de un PID muerto no debe bloquear (se recupera)."""
ruta = tmp_path / ".lock"
monkeypatch.setattr(lock, "LOCK_PATH", ruta)
ruta.write_text(json.dumps({"pid": 999999, "arranque": "2026-01-01T00:00:00"}), encoding="utf-8")
assert lock.adquirir() is True
lock.liberar()
def test_lock_liberar_no_borra_lock_ajeno(tmp_path, monkeypatch):
"""`liberar` solo elimina el lock si es de nuestro propio PID."""
ruta = tmp_path / ".lock"
monkeypatch.setattr(lock, "LOCK_PATH", ruta)
ruta.write_text(json.dumps({"pid": 999999, "arranque": "x"}), encoding="utf-8")
lock.liberar()
assert ruta.exists() # no era nuestro -> se respeta

45
tests/test_resumen.py Normal file
View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.resumen` — resumen IA y clasificacion (RF-11, RF-12, RF-13)."""
import monitor_estado.resumen as resumen
def test_resumen_clasifica_atencion():
def fake_ia(prompt):
return ('{"resumen_md": "Hay una incidencia de SQL.", '
'"requiere_atencion": [{"fuente": "jira", "quien": "DS-3648", '
'"texto": "PC526 sin conexion", "motivo": "ticket sin asignar"}]}')
r = resumen.generar({"jira": [{"key": "DS-3648", "resumen": "PC526 sin conexion"}]}, [], _ia=fake_ia)
assert r["fallo_ia"] is False
assert len(r["requiere_atencion"]) == 1
assert r["requiere_atencion"][0]["quien"] == "DS-3648"
assert "incidencia" in r["resumen_md"].lower()
def test_resumen_fallo_ia_deja_constancia():
def bad_ia(prompt):
raise RuntimeError("OmniRoute caido")
r = resumen.generar({"jira": [{"key": "DS-1", "resumen": "x"}]}, [("correo", "token caducado")], _ia=bad_ia)
assert r["fallo_ia"] is True
assert r["requiere_atencion"] == []
assert r["resumen_md"] # deja constancia del fallo
assert r["errores"] # conserva los errores del ciclo
def test_resumen_parsea_json_con_fences():
def fake_ia(prompt):
return '```json\n{"resumen_md": "ok", "requiere_atencion": []}\n```'
r = resumen.generar({"correo": [{"asunto": "hola"}]}, [], _ia=fake_ia)
assert r["fallo_ia"] is False and r["resumen_md"] == "ok"
def test_resumen_sin_novedades_no_llama_ia():
def no_llamar(prompt):
raise AssertionError("no deberia llamarse a la IA sin novedades")
r = resumen.generar({}, [], _ia=no_llamar)
assert r["fallo_ia"] is False
assert r["requiere_atencion"] == []

91
tests/test_telegram.py Normal file
View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.telegram` — nucleo y aislamiento (RF-1, RF-6)."""
from datetime import date, datetime, timezone
from types import SimpleNamespace
import monitor_estado.fuentes.telegram as tg
class FakeMsg:
"""Mensaje crudo minimo compatible con lo que espera normalizar/recoger."""
def __init__(self, id, dt, text, sender_id=1, sender=None):
self.id = id
self.date = dt
self.message = text
self.sender_id = sender_id
self._sender_name = sender
self._sender = sender or "Anon"
async def get_sender(self):
return SimpleNamespace(first_name=self._sender)
class FakeClient:
"""Cliente Telethon falso (sin red)."""
def __init__(self, por_id, malos=()):
self.por_id = por_id
self.malos = set(malos)
self.conectado = False
async def connect(self):
self.conectado = True
async def is_user_authorized(self):
return True
async def disconnect(self):
self.conectado = False
async def get_dialogs(self, limit=None):
return []
async def get_entity(self, cid):
if cid in self.malos:
raise RuntimeError("sin acceso a la fuente")
return cid
def iter_messages(self, entidad, limit=None):
async def gen():
for m in self.por_id.get(entidad, []):
yield m
return gen()
def test_normalizar_filtra_dia_y_ordena():
dia = date(2026, 10, 3)
m1 = FakeMsg(2, datetime(2026, 10, 3, 15, 0, tzinfo=timezone.utc), "segundo", sender="Ana")
m2 = FakeMsg(1, datetime(2026, 10, 3, 9, 0, tzinfo=timezone.utc), "primero", sender="Ana")
m3 = FakeMsg(3, datetime(2026, 10, 2, 9, 0, tzinfo=timezone.utc), "ayer", sender="Ana")
out = tg.normalizar_mensajes([m1, m2, m3], dia, "test")
assert [r["id"] for r in out] == [1, 2] # solo hoy y ordenado
assert out[0]["quien"] == "Ana" and out[0]["fuente"] == "test"
def test_recoger_fuente_para_en_el_dia():
dia = date(2026, 10, 3)
hoy = FakeMsg(5, datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc), "hoy", sender="Ana")
ayer = FakeMsg(4, datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc), "ayer", sender="Ana")
client = FakeClient({7: [hoy, ayer]})
import asyncio
out = asyncio.run(tg.recoger_fuente(client, "oscar", 7, dia))
assert [r["id"] for r in out] == [5]
def test_recoger_todas_aisla_fuente_caida():
dia = date(2026, 10, 3)
ok = FakeMsg(1, datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc), "hola", sender="Ana")
cfg = {"telegram": {"fuentes": [{"nombre": "buena", "id": 10}, {"nombre": "mala", "id": 20}]}}
client = FakeClient({10: [ok]}, malos={20})
resultado, errores = tg.recoger_todas(cfg, dia, client_factory=lambda c: client)
assert "buena" in resultado and len(resultado["buena"]) == 1
assert len(errores) == 1 and errores[0][0] == "mala"
def test_render_mensaje():
m = {"fecha": "2026-10-03T09:12:00+02:00", "quien": "Oscar", "texto": "linea1\nlinea2"}
txt = tg.render_mensaje(m)
assert "[09:12] Oscar" in txt and "linea1" in txt and "linea2" in txt

48
tests/test_vistos.py Normal file
View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.vistos` — dedup persistente (RF-8, RF-23)."""
import monitor_estado.vistos as vistos
def test_vistos_filtra_nuevos(tmp_path, monkeypatch):
"""Lo ya visto no se vuelve a contar; lo nuevo si (RF-8)."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
clave = "telegram_test"
msgs = [{"id": 1, "t": "a"}, {"id": 2, "t": "b"}]
nuevos = vistos.filtrar_nuevos(clave, msgs, id_fn=lambda m: m["id"])
assert [m["id"] for m in nuevos] == [1, 2]
# Segundo ciclo con los mismos mensajes -> nada nuevo.
assert vistos.filtrar_nuevos(clave, msgs, id_fn=lambda m: m["id"]) == []
# Llega uno nuevo -> solo ese.
mas = msgs + [{"id": 3, "t": "c"}]
assert [m["id"] for m in vistos.filtrar_nuevos(clave, mas, id_fn=lambda m: m["id"])] == [3]
def test_vistos_persiste_entre_llamadas(tmp_path, monkeypatch):
"""El estado se guarda en disco y sobrevive a una nueva lectura (RF-23)."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
clave = "correo"
vistos.filtrar_nuevos(clave, ["m1", "m2"], id_fn=lambda x: x)
assert (tmp_path / f"{clave}.json").exists()
# Nueva lectura del fichero: m1/m2 ya no son nuevos.
assert vistos.filtrar_nuevos(clave, ["m1", "m2"], id_fn=lambda x: x) == []
def test_vistos_marcar_explicito(tmp_path, monkeypatch):
"""`marcar` añade ids sin devolver nada."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
vistos.marcar("jira", ["DS-1", "DS-2"])
assert vistos.filtrar_nuevos("jira", ["DS-1"], id_fn=lambda x: x) == []
assert vistos.filtrar_nuevos("jira", ["DS-3"], id_fn=lambda x: x) == ["DS-3"]
def test_vistos_idempotente_sin_marcar(tmp_path, monkeypatch):
"""Con marcar_automatico=False, filtrar no altera el estado."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
a = vistos.filtrar_nuevos("k", [1, 2], id_fn=lambda x: x, marcar_automatico=False)
b = vistos.filtrar_nuevos("k", [1, 2], id_fn=lambda x: x, marcar_automatico=False)
assert a == [1, 2] and b == [1, 2]