metalfrom.eu/apps/crawler/tests/test_health.py
Nicolas Fryder 5237f3666d
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat: validation syntaxique du SQL + supervision des services de fond
Deux angles morts fermés.

1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js)
Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête
syntaxiquement invalide passait tous les tests et n'échouait qu'en production —
c'est précisément ce qui s'était produit avec crawler_pending.
Chaque requête réellement émise par les 28 routes est désormais parsée avec la
grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH
et les casts par type de resolve-conflict. 48 tests, aucun conteneur.
Limite déclarée explicitement : la sémantique n'est pas validée, et deux
requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue
si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il
passe au vert à vide.

2. Supervision des services de fond (migration 015)
Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP
ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un
worker à court de quota Geoapify, restait muet — le seul symptôme était
l'absence de données nouvelles, qu'il fallait remarquer soi-même.

Chaque service écrit un battement de cœur horaire dans service_health :
  - crawler  : base, FlareSolverr joignable, dernier run terminé < 12 h
  - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h
Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL
(> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier
contrôle réussi le ferait sinon passer pour sain indéfiniment.

Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou
silencieux en alerte actionnable.

Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle
surveille. Toute exception devient un échec de sonde, l'écriture du résultat et
la journalisation échouent en silence. Un contrôle de santé qui fait tomber le
crawler serait pire que pas de contrôle.

Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:20:15 +02:00

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 pytest
from src import health
from src.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