""" 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