metalfrom.eu/apps/crawler/tests/test_cancellation.py
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
Le dépôt n'avait aucun test, aucun linter, aucune vérification de types.

Outillage
- ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python
- tsc --checkJs sur l'API (pas de TypeScript, juste la vérification)
- Vitest : 401 tests JS ; pytest : 43 tests Python
- Tests de mutation (Stryker), deux profils : logique pure et API complète
- Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook,
  c'est donc la seule porte de qualité avant la mise en ligne
- Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré)

Sécurité
- Injection SQL authentifiée dans resolve-conflict : `field` était interpolé
  dans le SET sans allowlist
- timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401)
- setErrorHandler écrasait tous les 4xx en 500
- .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part
  alors que leur absence casse toute connexion admin

Annulation réelle des crawl_run (migration 014)
- L'API posait status='error' sans que le crawler en sache rien : le process
  continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur)
- Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler
  écrit lui-même status='cancelled'

Cohérence géographique (migration 014)
- Le trigger 013 supprimait les band_locations sans purger le point dénormalisé
- L'édition admin de lat/lon n'atteignait jamais band_locations : la carte
  ignorait la correction. Override step_order = -1, dans une transaction

Corrections
- limit/offset NaN → 500 au lieu de 400
- OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie)
- listen() sans catch, cast ::text en dur sur les colonnes numériques
- /admin/api/logs ne renvoyait pas sa pagination
- a11y : sélecteur de langue annoncé comme liste vide (role=option manquant)

Nettoyage
- apps/web/quizz-site supprimé (sans rapport avec le projet)
- Code mort : openModal(), LANG_NAMES, double import, variables inutilisées
- .dockerignore ajoutés ; node_modules racine n'était pas gitignoré

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 10:05:40 +02:00

172 lines
5.9 KiB
Python

"""
Annulation coopérative des crawl_run.
C'est la moitié qui fait le travail réel : l'API ne fait que poser un drapeau
(voir apps/api/test/cancellation.test.js), c'est le crawler qui doit l'observer
et s'arrêter. Sans ces tests, le bouton « Annuler » pourrait redevenir
cosmétique sans que rien ne le signale.
Aucune connexion Postgres : `get_conn` est remplacé par un faux curseur.
"""
import contextlib
from unittest.mock import patch
import pytest
from src import db
from src.db import RunCancelled, is_cancel_requested, raise_if_cancelled
class FakeCursor:
"""Curseur minimal qui rejoue une file de résultats et compte les requêtes."""
def __init__(self, results, fail=False):
self._results = list(results)
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 fake_conn(cursor):
@contextlib.contextmanager
def _get_conn():
class Conn:
def cursor(self_inner):
return cursor
yield Conn()
return _get_conn
@pytest.fixture(autouse=True)
def _clear_cache():
"""Le drapeau est mis en cache par run_id : repartir propre à chaque test."""
db._cancel_cache.clear()
yield
db._cancel_cache.clear()
class TestIsCancelRequested:
def test_false_quand_le_drapeau_est_baisse(self):
cur = FakeCursor([(False,)])
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(1) is False
def test_true_quand_le_drapeau_est_leve(self):
cur = FakeCursor([(True,)])
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(1) is True
def test_false_quand_le_run_nexiste_pas(self):
cur = FakeCursor([])
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(999) is False
def test_le_resultat_est_mis_en_cache(self):
"""Le check est appelé à chaque lot : il ne doit pas faire une requête par appel."""
cur = FakeCursor([(False,), (False,), (False,)])
with patch.object(db, "get_conn", fake_conn(cur)):
for _ in range(5):
is_cancel_requested(1)
assert len(cur.queries) == 1
def test_force_contourne_le_cache(self):
cur = FakeCursor([(False,), (True,)])
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(1) is False
assert is_cancel_requested(1, force=True) is True
assert len(cur.queries) == 2
def test_le_cache_est_par_run(self):
cur = FakeCursor([(True,), (False,)])
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(1) is True
assert is_cancel_requested(2) is False
# Une base momentanément injoignable ne doit PAS faire passer un crawl
# sain pour un crawl annulé.
def test_une_erreur_db_ne_declenche_pas_dannulation(self):
cur = FakeCursor([], fail=True)
with patch.object(db, "get_conn", fake_conn(cur)):
assert is_cancel_requested(1) is False
def test_une_erreur_db_conserve_la_derniere_valeur_connue(self):
ok = FakeCursor([(True,)])
with patch.object(db, "get_conn", fake_conn(ok)):
assert is_cancel_requested(1) is True
db._cancel_cache[1] = (0.0, True) # cache expiré, valeur connue = True
ko = FakeCursor([], fail=True)
with patch.object(db, "get_conn", fake_conn(ko)):
assert is_cancel_requested(1) is True
def test_interroge_bien_la_colonne_cancel_requested(self):
cur = FakeCursor([(False,)])
with patch.object(db, "get_conn", fake_conn(cur)):
is_cancel_requested(42)
sql, params = cur.queries[0]
assert "cancel_requested" in sql
assert "crawl_run" in sql
assert params == (42,)
class TestRaiseIfCancelled:
def test_ne_leve_pas_quand_le_drapeau_est_baisse(self):
cur = FakeCursor([(False,)])
with patch.object(db, "get_conn", fake_conn(cur)):
raise_if_cancelled(1) # ne doit pas lever
def test_leve_RunCancelled_quand_le_drapeau_est_leve(self):
cur = FakeCursor([(True,)])
with patch.object(db, "get_conn", fake_conn(cur)):
with pytest.raises(RunCancelled):
raise_if_cancelled(1)
# RunCancelled doit rester attrapable par les `except Exception` existants
# tout en étant distinguable — d'où l'ordre des clauses dans jobs.py.
def test_RunCancelled_derive_de_Exception(self):
assert issubclass(RunCancelled, Exception)
class TestFinishCrawlRun:
def _run_finish(self, **kwargs):
cur = FakeCursor([])
with patch.object(db, "get_conn", fake_conn(cur)):
db.finish_crawl_run(1, {"seen": 5}, **kwargs)
return cur.queries[0]
def test_statut_done_par_defaut(self):
_sql, params = self._run_finish()
assert params[0] == "done"
def test_statut_error_avec_une_erreur(self):
_sql, params = self._run_finish(error="boom")
assert params[0] == "error"
# Un run annulé n'est pas un échec : il ne doit pas remonter dans les erreurs.
def test_statut_cancelled_quand_annule(self):
_sql, params = self._run_finish(cancelled=True)
assert params[0] == "cancelled"
def test_cancelled_prime_sur_error(self):
_sql, params = self._run_finish(error="interrompu", cancelled=True)
assert params[0] == "cancelled"
def test_le_cache_dannulation_est_purge(self):
db._cancel_cache[1] = (999.0, True)
self._run_finish(cancelled=True)
assert 1 not in db._cancel_cache