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>
172 lines
5.9 KiB
Python
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
|