health.py existait en deux copies rigoureusement identiques — crawler et geocoder — dont une seule était couverte par des tests. Rien ne signalait une divergence : une correction appliquée d'un seul côté serait passée inaperçue. Le module part dans libs/bm_health.py, embarqué dans les images via un contexte de build ramené à la racine du dépôt, et rendu importable en local par les conftest.py. Un .dockerignore racine évite que node_modules parte dans les images au passage. Les deux images ont été reconstruites et le module vérifié importable à l'exécution dans chacune. La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de l'admin ne pouvait donc rien signaler — y compris quand le service était mort. Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec des sondes propres à leur rôle (clé API, progression de leur file). Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle. /admin/api/locations/reset-llm était pourtant le seul moyen de relancer les lieux passés en 'manual' après épuisement des tentatives LLM : la fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec /admin/api/live et disparaît, avec ses tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
7.8 KiB
Python
217 lines
7.8 KiB
Python
"""
|
|
Contrôles de santé des services de fond.
|
|
|
|
Règle cardinale : une sonde ne doit JAMAIS interrompre le service qu'elle
|
|
surveille. Un contrôle de santé qui fait tomber le crawler serait pire que pas
|
|
de contrôle du tout — d'où le nombre de cas d'échec vérifiés ici.
|
|
"""
|
|
|
|
import contextlib
|
|
|
|
import bm_health as health
|
|
import pytest
|
|
from bm_health import (
|
|
database_probe,
|
|
freshness_probe,
|
|
http_probe,
|
|
maybe_run,
|
|
probe,
|
|
run_checks,
|
|
write_health,
|
|
)
|
|
|
|
|
|
class FakeCursor:
|
|
def __init__(self, results=None, fail=False):
|
|
self._results = list(results or [])
|
|
self.queries = []
|
|
self.fail = fail
|
|
|
|
def execute(self, sql, params=None):
|
|
if self.fail:
|
|
raise RuntimeError("base injoignable")
|
|
self.queries.append((sql, params))
|
|
|
|
def fetchone(self):
|
|
return self._results.pop(0) if self._results else None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
|
|
def conn_factory(cursor):
|
|
@contextlib.contextmanager
|
|
def _get_conn():
|
|
class Conn:
|
|
def cursor(self_inner):
|
|
return cursor
|
|
yield Conn()
|
|
return _get_conn
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_interval():
|
|
health._last_run = 0.0
|
|
yield
|
|
health._last_run = 0.0
|
|
|
|
|
|
class TestProbe:
|
|
def test_transmet_le_resultat(self):
|
|
assert probe("x", lambda: (True, "ok")) == ("x", True, "ok")
|
|
|
|
def test_convertit_une_exception_en_echec(self):
|
|
def boom():
|
|
raise ValueError("cassé")
|
|
name, ok, detail = probe("x", boom)
|
|
assert (name, ok) == ("x", False)
|
|
assert "ValueError" in detail and "cassé" in detail
|
|
|
|
def test_normalise_un_detail_absent(self):
|
|
assert probe("x", lambda: (True, None))[2] == ""
|
|
|
|
|
|
class TestDatabaseProbe:
|
|
def test_ok_quand_la_base_repond(self):
|
|
cur = FakeCursor([(1,)])
|
|
assert database_probe(conn_factory(cur))()[0] is True
|
|
# La sonde doit réellement interroger la base, pas se contenter
|
|
# d'ouvrir une connexion.
|
|
assert cur.queries and "SELECT 1" in cur.queries[0][0]
|
|
|
|
def test_echoue_sans_lever_quand_la_base_est_injoignable(self):
|
|
cur = FakeCursor(fail=True)
|
|
name, ok, detail = probe("database", database_probe(conn_factory(cur)))
|
|
assert ok is False
|
|
assert "injoignable" in detail
|
|
|
|
|
|
class TestHttpProbe:
|
|
def test_ok_sur_200(self):
|
|
assert http_probe(lambda url, timeout: type("R", (), {"status_code": 200})(), "http://x")()[0] is True
|
|
|
|
def test_echec_sur_500(self):
|
|
ok, detail = http_probe(lambda url, timeout: type("R", (), {"status_code": 503})(), "http://x")()
|
|
assert ok is False
|
|
assert "503" in detail
|
|
|
|
def test_une_erreur_reseau_devient_un_echec_de_sonde(self):
|
|
def boom(url, timeout):
|
|
raise ConnectionError("nom d'hôte introuvable")
|
|
name, ok, detail = probe("dep", http_probe(boom, "http://x"))
|
|
assert ok is False
|
|
assert "ConnectionError" in detail
|
|
|
|
|
|
class TestFreshnessProbe:
|
|
def _probe(self, latest, age_h):
|
|
cur = FakeCursor([(latest,), (age_h,)])
|
|
return freshness_probe(conn_factory(cur), "SELECT max(x) FROM t", 12, "test")()
|
|
|
|
def test_ok_quand_les_donnees_sont_fraiches(self):
|
|
assert self._probe("2026-08-18T10:00:00Z", 3.0)[0] is True
|
|
|
|
def test_echec_quand_les_donnees_sont_trop_anciennes(self):
|
|
ok, detail = self._probe("2026-08-01T10:00:00Z", 40.0)
|
|
assert ok is False
|
|
assert "40.0 h" in detail and "seuil 12 h" in detail
|
|
|
|
def test_borne_exacte_acceptee(self):
|
|
assert self._probe("x", 12.0)[0] is True
|
|
|
|
# Une base vide au premier démarrage n'est pas une panne.
|
|
def test_absence_de_donnees_nest_pas_un_echec(self):
|
|
cur = FakeCursor([(None,)])
|
|
ok, detail = freshness_probe(conn_factory(cur), "SELECT max(x) FROM t", 12, "test")()
|
|
assert ok is True
|
|
assert "aucune donnée" in detail
|
|
|
|
|
|
class TestWriteHealth:
|
|
def test_agrege_les_sondes_et_enregistre(self):
|
|
cur = FakeCursor()
|
|
ok, failed = write_health(conn_factory(cur), "crawler",
|
|
[("database", True, "ok"), ("dep", True, "ok")])
|
|
assert ok is True and failed == []
|
|
sql, params = cur.queries[0]
|
|
assert "INSERT INTO service_health" in sql
|
|
assert "ON CONFLICT (service) DO UPDATE" in sql # une ligne par service
|
|
assert params[0] == "crawler" and params[1] is True
|
|
|
|
def test_une_sonde_en_echec_rend_le_service_degrade(self):
|
|
cur = FakeCursor()
|
|
ok, failed = write_health(conn_factory(cur), "crawler",
|
|
[("database", True, "ok"), ("dep", False, "HTTP 503")])
|
|
assert ok is False
|
|
assert failed == ["dep — HTTP 503"]
|
|
params = cur.queries[0][1]
|
|
assert params[1] is False
|
|
assert "dep" in params[3] # la première panne est lisible telle quelle
|
|
|
|
def test_le_detail_par_sonde_est_conserve(self):
|
|
cur = FakeCursor()
|
|
write_health(conn_factory(cur), "crawler", [("a", True, ""), ("b", False, "")])
|
|
import json
|
|
assert json.loads(cur.queries[0][1][2]) == {"a": True, "b": False}
|
|
|
|
# Si la base est en panne, la sonde base l'a déjà signalé : échouer ici en
|
|
# plus ferait tomber le service pour rien.
|
|
def test_une_base_injoignable_ne_fait_pas_tomber_le_service(self):
|
|
cur = FakeCursor(fail=True)
|
|
ok, failed = write_health(conn_factory(cur), "crawler", [("database", False, "ko")])
|
|
assert ok is False # pas d'exception propagée
|
|
|
|
|
|
class TestRunChecks:
|
|
def test_journalise_les_sondes_en_echec(self):
|
|
cur = FakeCursor()
|
|
messages = []
|
|
run_checks(conn_factory(cur), "crawler",
|
|
[("dep", lambda: (False, "HTTP 503"))],
|
|
log_event=lambda lvl, msg: messages.append((lvl, msg)))
|
|
assert messages and messages[0][0] == "warning"
|
|
assert "DÉGRADÉ" in messages[0][1] and "dep" in messages[0][1]
|
|
|
|
def test_ne_journalise_rien_quand_tout_va_bien(self):
|
|
cur = FakeCursor()
|
|
messages = []
|
|
run_checks(conn_factory(cur), "crawler", [("dep", lambda: (True, "ok"))],
|
|
log_event=lambda lvl, msg: messages.append(msg))
|
|
assert messages == []
|
|
|
|
def test_un_log_event_defaillant_ne_fait_pas_tomber_le_controle(self):
|
|
cur = FakeCursor()
|
|
def boom(lvl, msg):
|
|
raise RuntimeError("log cassé")
|
|
assert run_checks(conn_factory(cur), "crawler",
|
|
[("dep", lambda: (False, "ko"))], log_event=boom) is False
|
|
|
|
|
|
class TestMaybeRun:
|
|
def test_execute_au_premier_appel(self):
|
|
cur = FakeCursor()
|
|
assert maybe_run(conn_factory(cur), "crawler", [("d", lambda: (True, ""))]) is True
|
|
|
|
# Appelée depuis une boucle qui tourne toutes les minutes, la fonction ne
|
|
# doit pas sonder les dépendances externes à chaque passage.
|
|
def test_ne_reexecute_pas_avant_lintervalle(self):
|
|
cur = FakeCursor()
|
|
probes = [("d", lambda: (True, ""))]
|
|
maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600)
|
|
assert maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600) is None
|
|
|
|
def test_force_contourne_lintervalle(self):
|
|
cur = FakeCursor()
|
|
probes = [("d", lambda: (True, ""))]
|
|
maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600)
|
|
assert maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600, force=True) is True
|
|
|
|
def test_reexecute_une_fois_lintervalle_ecoule(self):
|
|
cur = FakeCursor()
|
|
probes = [("d", lambda: (True, ""))]
|
|
maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600)
|
|
health._last_run -= 4000 # simule le temps écoulé
|
|
assert maybe_run(conn_factory(cur), "crawler", probes, interval_s=3600) is True
|