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>
129 lines
4.9 KiB
Python
129 lines
4.9 KiB
Python
"""
|
|
Tests du parseur de localisations Metal Archives.
|
|
|
|
Aucun accès réseau ni base : `parser.py` n'a que des fonctions pures, ce qui en
|
|
fait le module le plus rentable à couvrir de tout le pipeline de géocodage —
|
|
c'est lui qui décide combien de lignes `band_locations` sont créées, et donc
|
|
combien d'appels Geoapify/Groq (payants) seront déclenchés.
|
|
"""
|
|
|
|
import pytest
|
|
from src.parser import (
|
|
_classify,
|
|
build_fallback_queries,
|
|
country_centroid,
|
|
parse_location_text,
|
|
)
|
|
|
|
|
|
class TestClassify:
|
|
@pytest.mark.parametrize("value", ["N/A", "n/a", "", " "])
|
|
def test_valeurs_vides_ignorees(self, value):
|
|
assert _classify(value) == "skip"
|
|
|
|
@pytest.mark.parametrize("value", ["FR", "DE", "NO"])
|
|
def test_codes_iso2(self, value):
|
|
assert _classify(value) == "country_code"
|
|
|
|
def test_ville_normale(self):
|
|
assert _classify("Oslo") == "parseable"
|
|
|
|
|
|
class TestParseLocationText:
|
|
def test_texte_vide_ne_produit_aucune_ligne(self):
|
|
assert parse_location_text("") == []
|
|
assert parse_location_text("N/A") == []
|
|
|
|
def test_ville_simple(self):
|
|
rows = parse_location_text("Oslo")
|
|
assert len(rows) == 1
|
|
assert rows[0]["location_raw"] == "Oslo"
|
|
assert rows[0]["is_country_only"] is False
|
|
assert rows[0]["step_order"] == 0
|
|
|
|
def test_ville_avec_region(self):
|
|
rows = parse_location_text("Bergen, Hordaland")
|
|
assert [r["location_raw"] for r in rows] == ["Bergen, Hordaland"]
|
|
|
|
def test_code_pays_seul_marque_country_only(self):
|
|
rows = parse_location_text("FR")
|
|
assert len(rows) == 1
|
|
assert rows[0]["is_country_only"] is True
|
|
assert rows[0]["location_raw"] == "FR"
|
|
|
|
def test_code_pays_minuscule_non_reconnu(self):
|
|
"""Comportement actuel documenté, pas une validation.
|
|
|
|
`_ISO2_RE` vaut `^[A-Z]{2}$` : un code pays en minuscules n'est PAS
|
|
reconnu comme pays et repart en géocodage comme s'il s'agissait d'une
|
|
ville, ce qui consomme un appel Geoapify pour un résultat au mieux
|
|
douteux. Metal Archives renvoie les codes en majuscules, donc le cas ne
|
|
se produit pas en pratique aujourd'hui — mais rien ne le garantit.
|
|
Si `_ISO2_RE` devient insensible à la casse, ce test doit être inversé.
|
|
"""
|
|
rows = parse_location_text("fr")
|
|
assert rows[0]["is_country_only"] is False
|
|
|
|
def test_plusieurs_villes_separees(self):
|
|
"""Une même étape peut lister plusieurs villes : chacune devient une ligne."""
|
|
rows = parse_location_text("Oslo/Bergen")
|
|
assert len(rows) >= 2
|
|
assert {r["location_raw"] for r in rows} >= {"Oslo", "Bergen"}
|
|
# Même étape → même step_order
|
|
assert len({r["step_order"] for r in rows}) == 1
|
|
|
|
def test_etapes_successives_incrementent_step_order(self):
|
|
rows = parse_location_text("Oslo (early), Bergen (later)")
|
|
orders = [r["step_order"] for r in rows]
|
|
assert orders == sorted(orders)
|
|
|
|
def test_les_valeurs_ignorables_ne_creent_pas_de_ligne(self):
|
|
rows = parse_location_text("N/A")
|
|
assert rows == []
|
|
|
|
def test_resultat_toujours_serialisable(self):
|
|
"""Chaque ligne doit exposer exactement les clés attendues par db.py."""
|
|
for row in parse_location_text("Trondheim (1991-1993), Oslo"):
|
|
assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"}
|
|
assert isinstance(row["step_order"], int)
|
|
assert isinstance(row["is_country_only"], bool)
|
|
|
|
|
|
class TestCountryCentroid:
|
|
def test_code_iso2_connu(self):
|
|
lat, lon = country_centroid("FR")
|
|
assert 41 < lat < 52
|
|
assert -6 < lon < 10
|
|
|
|
def test_insensible_a_la_casse_et_aux_espaces(self):
|
|
assert country_centroid(" fr ") == country_centroid("FR")
|
|
|
|
def test_code_inconnu_renvoie_none(self):
|
|
assert country_centroid("ZZ") is None
|
|
|
|
|
|
class TestBuildFallbackQueries:
|
|
def test_du_plus_specifique_au_plus_vague(self):
|
|
queries = build_fallback_queries("Bergen, Hordaland", "NO")
|
|
assert queries[0].startswith("Bergen, Hordaland")
|
|
assert queries[-1] == "Bergen"
|
|
|
|
def test_aucun_doublon(self):
|
|
queries = build_fallback_queries("Oslo", "NO")
|
|
assert len(queries) == len(set(queries))
|
|
|
|
def test_le_pays_est_injecte_comme_contexte(self):
|
|
queries = build_fallback_queries("Bergen", "NO")
|
|
assert any("Norway" in q for q in queries)
|
|
|
|
def test_pays_deja_present_non_duplique(self):
|
|
queries = build_fallback_queries("Bergen, Norway", "NO")
|
|
assert not any(q.count("Norway") > 1 for q in queries)
|
|
|
|
def test_pays_inconnu_ne_plante_pas(self):
|
|
assert build_fallback_queries("Bergen", "ZZ") == ["Bergen"]
|
|
assert build_fallback_queries("Bergen", None) == ["Bergen"]
|
|
|
|
def test_toujours_au_moins_une_requete(self):
|
|
for raw in ["Oslo", "A, B, C, D", "Saint-Étienne"]:
|
|
assert len(build_fallback_queries(raw, "FR")) >= 1
|