metalfrom.eu/apps/geocoder/tests/test_parser.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

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