diff --git a/apps/crawler/tests/test_network_io.py b/apps/crawler/tests/test_network_io.py
new file mode 100644
index 0000000..4d13b53
--- /dev/null
+++ b/apps/crawler/tests/test_network_io.py
@@ -0,0 +1,324 @@
+"""
+Couche réseau du crawler : FlareSolverr et le client Metal Archives.
+
+Aucune requête réelle n'est émise — le transport est remplacé par un double.
+Ce qui est testé, c'est la LOGIQUE autour du réseau : quand réessayer, quand
+abandonner, comment extraire les données d'une réponse enveloppée par
+FlareSolverr. C'est là que sont les bugs, pas dans `requests.get`.
+
+Enjeu concret : ces retours conditionnent le nombre de requêtes envoyées à
+Metal Archives. Une boucle de retry mal bornée nous ferait bannir.
+"""
+
+import json
+import time
+from unittest.mock import Mock
+
+import pytest
+from src import ma_http
+from src.flaresolverr import FlareSolverr
+from src.ma_http import (
+ MASession,
+ _build_url,
+ _clean,
+ _extract_json,
+ _extract_link,
+ _extract_ma_id,
+ _parse_archive_row,
+ _parse_list_row,
+)
+
+
+# ------------------------------------------------------------------
+# Helpers purs
+# ------------------------------------------------------------------
+class TestBuildUrl:
+ def test_sans_parametre(self):
+ assert _build_url("http://x/y") == "http://x/y"
+
+ def test_encode_les_parametres(self):
+ assert _build_url("http://x", {"a": "1", "b": "deux mots"}) == "http://x?a=1&b=deux+mots"
+
+ def test_parametres_vides_ne_changent_rien(self):
+ assert _build_url("http://x", {}) == "http://x"
+
+
+class TestExtractJson:
+ """FlareSolverr enveloppe le JSON dans
en échappant les chevrons."""
+
+ def test_extrait_depuis_pre(self):
+ assert _extract_json('{"a": 1}') == {"a": 1}
+
+ def test_deshechappe_les_entites_html(self):
+ # MA renvoie du HTML dans les champs JSON ; FlareSolverr échappe les
+ # chevrons de la page entière. Sans unescape, les fragments arrivent
+ # sous forme <a> et _extract_link n'y trouve plus de href.
+ body = "{\"h\": \"<a>T</a>\"}"
+ assert _extract_json(body)["h"] == "T"
+
+ def test_accepte_un_json_nu(self):
+ assert _extract_json('{"a": 2}') == {"a": 2}
+
+ def test_json_invalide_leve(self):
+ with pytest.raises(json.JSONDecodeError):
+ _extract_json("pas du json
")
+
+ def test_prend_le_premier_bloc_pre(self):
+ assert _extract_json('{"a":1}{"b":2}') == {"a": 1}
+
+
+class TestExtractLink:
+ @pytest.mark.parametrize("frag,href,text", [
+ ('Mayhem', "/bands/Mayhem/67", "Mayhem"),
+ ("Y", "/x", "Y"),
+ ("texte sans lien", None, "texte sans lien"),
+ ("", None, ""),
+ (None, None, ""),
+ ])
+ def test_extrait_href_et_texte(self, frag, href, text):
+ assert _extract_link(frag) == (href, text)
+
+ def test_retire_les_balises_imbriquees(self):
+ _, text = _extract_link('Gras suite')
+ assert "<" not in text
+
+
+class TestExtractMaId:
+ @pytest.mark.parametrize("url,attendu", [
+ ("/bands/Mayhem/67", 67),
+ ("https://www.metal-archives.com/bands/Darkthrone/146", 146),
+ # ma_id dépasse 2^31 dans les données réelles : la colonne est BIGINT.
+ ("/bands/X/3500000000", 3500000000),
+ ("/bands/SansId", None),
+ ("", None),
+ (None, None),
+ ])
+ def test_extrait_lidentifiant(self, url, attendu):
+ assert _extract_ma_id(url) == attendu
+
+
+class TestClean:
+ @pytest.mark.parametrize("brut,attendu", [
+ ("Black Metal", "Black Metal"),
+ (" espaces ", "espaces"),
+ (None, ""),
+ ("", ""),
+ (123, "123"),
+ ])
+ def test_nettoie(self, brut, attendu):
+ assert _clean(brut) == attendu
+
+
+# ------------------------------------------------------------------
+# Parseurs de lignes
+# ------------------------------------------------------------------
+class TestParseListRow:
+ ROW = ['Mayhem', "Black Metal", "Oslo", "Active"]
+
+ def test_ligne_complete(self):
+ out = _parse_list_row(self.ROW, "NO")
+ assert out == {
+ "ma_id": 67, "name": "Mayhem", "url": "/bands/Mayhem/67",
+ "country": "NO", "genre": "Black Metal",
+ "location_text": "Oslo", "status": "Active",
+ }
+
+ def test_statut_optionnel(self):
+ assert _parse_list_row(self.ROW[:3], "NO")["status"] is None
+
+ @pytest.mark.parametrize("row", [
+ [], # vide
+ ["Y", "g"], # trop courte
+ ["pas de lien", "g", "l"], # sans href → pas d'id
+ ['X', "g", "l"], # id non numérique
+ ['', "g", "l"], # nom vide
+ ])
+ def test_ligne_inexploitable_renvoie_none(self, row):
+ # Renvoyer None plutôt que lever : une ligne malformée ne doit pas
+ # interrompre le crawl des 800 autres.
+ assert _parse_list_row(row, "NO") is None
+
+
+class TestParseArchiveRow:
+ ROW = ["June 1", 'Foo',
+ 'Germany', "Black Metal", "Jun 1st", "u"]
+
+ def test_ligne_complete(self):
+ out = _parse_archive_row(self.ROW, "created")
+ assert out["ma_id"] == 123
+ assert out["name"] == "Foo"
+ assert out["country"] == "DE"
+ assert out["genre"] == "Black Metal"
+
+ def test_le_pays_vient_du_lien_de_liste(self):
+ assert _parse_archive_row(self.ROW, "created")["country"] == "DE"
+
+ def test_sans_lien_pays_retombe_sur_le_texte(self):
+ row = list(self.ROW)
+ row[2] = "Germany"
+ assert _parse_archive_row(row, "created")["country"] == "Germany"
+
+ # Le format MA n'inclut pas l'année : le filtre par date est désactivé et
+ # on re-lit les ~800 dernières entrées à chaque run. Verrouillé ici pour
+ # qu'une « optimisation » ne réintroduise pas un filtrage faux.
+ def test_date_str_est_toujours_none(self):
+ assert _parse_archive_row(self.ROW, "created")["date_str"] is None
+
+ @pytest.mark.parametrize("row", [[], ["a", "b", "c"], ["a", "sans lien", "c", "d"]])
+ def test_ligne_inexploitable_renvoie_none(self, row):
+ assert _parse_archive_row(row, "created") is None
+
+
+# ------------------------------------------------------------------
+# FlareSolverr
+# ------------------------------------------------------------------
+def fake_fs(response_json, status_code=200):
+ fs = FlareSolverr("http://fs:8191")
+ resp = Mock(status_code=status_code)
+ resp.json.return_value = response_json
+ resp.raise_for_status = Mock()
+ fs._call = Mock(return_value=response_json)
+ return fs
+
+
+class TestFlareSolverr:
+ def test_get_renvoie_statut_et_corps(self):
+ fs = fake_fs({"solution": {"status": 200, "response": "ok"}})
+ assert fs.get("http://x") == (200, "ok")
+
+ def test_solution_absente_ne_leve_pas(self):
+ # FlareSolverr renvoie parfois une enveloppe sans solution : on veut un
+ # statut 0 exploitable par la logique de retry, pas une exception.
+ fs = fake_fs({})
+ status, body = fs.get("http://x")
+ assert status == 0
+
+ def test_transmet_lidentifiant_de_session(self):
+ fs = fake_fs({"solution": {"status": 200, "response": ""}})
+ fs.get("http://x", session_id="sess-1")
+ payload = fs._call.call_args[0][0]
+ assert payload.get("session") == "sess-1"
+
+ def test_healthy_reflete_la_reponse(self):
+ fs = fake_fs({"status": "ok"})
+ assert fs.healthy() in (True, False) # ne doit jamais lever
+
+
+# ------------------------------------------------------------------
+# MASession : logique de réessai
+# ------------------------------------------------------------------
+class FakeFS:
+ """Rejoue une séquence de (status, body) pour les vraies requêtes.
+
+ ensure_session() effectue un GET de préchauffage (cookies Cloudflare) à
+ chaque création ou rafraîchissement de session. Ces appels sont comptés
+ séparément : les mélanger aux vraies requêtes rendait tous les comptages
+ incompréhensibles.
+ """
+
+ WARMUP = ma_http._WARMUP_URL
+
+ def __init__(self, sequence):
+ self.sequence = list(sequence)
+ self.calls = 0 # requêtes utiles uniquement
+ self.warmups = 0
+ self.sessions_created = 0
+
+ def create_session(self):
+ self.sessions_created += 1
+ return f"sess-{self.sessions_created}"
+
+ def destroy_session(self, sid):
+ pass
+
+ def get(self, url, session_id=None):
+ if url == self.WARMUP:
+ self.warmups += 1
+ return 200, ""
+ self.calls += 1
+ return self.sequence[min(self.calls - 1, len(self.sequence) - 1)]
+
+
+@pytest.fixture(autouse=True)
+def _no_sleep(monkeypatch):
+ """Neutralise les temporisations de politesse : les tests doivent être instantanés."""
+ monkeypatch.setattr(ma_http, "sleep_range", lambda *a, **k: None)
+
+
+class TestMASessionRetry:
+ def _session(self, sequence):
+ fs = FakeFS(sequence)
+ s = MASession(fs)
+ # Session déjà établie : on veut compter les requêtes utiles, pas la
+ # création initiale. L'attribut est privé côté implémentation.
+ s._session_id = "sess-0"
+ s._session_age = time.time()
+ return s, fs
+
+ def test_reussite_immediate(self):
+ s, fs = self._session([(200, '{"a":1}')])
+ assert s.get_json("http://x") == {"a": 1}
+ assert fs.calls == 1
+
+ @pytest.mark.parametrize("status", [403, 429, 503])
+ def test_reessaie_sur_blocage_puis_reussit(self, status):
+ s, fs = self._session([(status, ""), (200, '{"a":1}')])
+ assert s.get_json("http://x") == {"a": 1}
+ assert fs.calls == 2
+ # Un blocage justifie une session Chrome neuve, pas un simple retry :
+ # les cookies Cloudflare sont probablement grillés.
+ assert fs.sessions_created >= 1
+ assert fs.warmups >= 1
+
+ @pytest.mark.parametrize("status", [403, 429, 503])
+ def test_abandonne_apres_le_dernier_essai(self, status):
+ s, fs = self._session([(status, "")])
+ with pytest.raises(RuntimeError, match=f"HTTP {status}"):
+ s.get_json("http://x", retries=2)
+ # Borne stricte : retries=2 → 3 tentatives, jamais plus. Une boucle non
+ # bornée nous ferait bannir de Metal Archives.
+ assert fs.calls == 3
+
+ def test_un_statut_non_bloquant_nest_pas_reessaye(self):
+ # 500 n'est pas un blocage anti-bot : réessayer ne servirait qu'à
+ # marteler un serveur déjà en difficulté.
+ s, fs = self._session([(500, "")])
+ with pytest.raises(RuntimeError, match="HTTP 500"):
+ s.get_json("http://x", retries=2)
+ assert fs.calls == 1
+
+ def test_json_invalide_est_reessaye_puis_leve(self):
+ s, fs = self._session([(200, "pas du json
")])
+ with pytest.raises(RuntimeError, match="JSON parse error"):
+ s.get_json("http://x", retries=1)
+ assert fs.calls == 2
+
+ def test_json_invalide_puis_valide(self):
+ s, fs = self._session([(200, "invalide
"), (200, '{"ok":1}')])
+ assert s.get_json("http://x", retries=2) == {"ok": 1}
+
+ def test_get_html_reessaie_puis_reussit(self):
+ s, fs = self._session([(429, ""), (200, "ok")])
+ assert s.get_html("http://x") == "ok"
+ assert fs.calls == 2
+
+ def test_get_html_abandonne_avec_le_bon_message(self):
+ s, fs = self._session([(403, "")])
+ with pytest.raises(RuntimeError, match="HTTP 403"):
+ s.get_html("http://x", retries=1)
+ assert fs.calls == 2
+
+ def test_retries_zero_ne_fait_quun_appel(self):
+ s, fs = self._session([(503, "")])
+ with pytest.raises(RuntimeError):
+ s.get_json("http://x", retries=0)
+ assert fs.calls == 1
+
+ def test_une_session_est_creee_si_absente(self):
+ fs = FakeFS([(200, '{"a":1}')])
+ s = MASession(fs)
+ s.get_json("http://x")
+ assert fs.sessions_created == 1
+ # Le préchauffage récupère les cookies Cloudflare avant toute requête
+ # utile : sans lui, la première tomberait systématiquement en 403.
+ assert fs.warmups == 1
diff --git a/apps/crawler/tests/test_scraper_band.py b/apps/crawler/tests/test_scraper_band.py
new file mode 100644
index 0000000..b995496
--- /dev/null
+++ b/apps/crawler/tests/test_scraper_band.py
@@ -0,0 +1,204 @@
+"""
+Parseur des pages Metal Archives.
+
+Module le plus exposé du crawler : c'est lui qui extrait genre, statut, thèmes,
+line-up et dates. Il n'avait AUCUN test. Si Metal Archives change son HTML, il
+renvoie silencieusement des champs vides et le crawler enregistre des fiches
+creuses sans lever la moindre erreur — la panne la plus coûteuse possible,
+parce qu'elle est invisible.
+
+Les fixtures reproduisent la structure réelle des pages (dl/dt/dd pour les
+stats, table.lineupTable pour le line-up, #auditTrail pour les dates).
+"""
+
+import hashlib
+
+import pytest
+from src.scraper_band import page_hash, parse_band_page
+
+
+def page(stats="", lineup="", extra=""):
+ rows = "".join(f"{k}:{v}" for k, v in stats) if stats else ""
+ return f"""
+
+
+
+ {lineup}{extra}
+ """
+
+
+FULL_STATS = [
+ ("Country of origin", "Norway"),
+ ("Location", "Oslo"),
+ ("Status", "Active"),
+ ("Formed in", "1984"),
+ ("Genre", "Black Metal"),
+ ("Lyrical themes", "Death, Satanism, Darkness"),
+ ("Current label", "Century Media"),
+ ("Years active", "1984-1993, 1995-present"),
+]
+
+
+class TestChampsPrincipaux:
+ def test_extrait_le_nom(self):
+ assert parse_band_page(page(FULL_STATS))["name"] == "Mayhem"
+
+ @pytest.mark.parametrize("champ,attendu", [
+ ("status", "Active"),
+ ("genre", "Black Metal"),
+ ("formed_in", "1984"),
+ ("location", "Oslo"),
+ ("years_active", "1984-1993, 1995-present"),
+ ("label", "Century Media"),
+ ])
+ def test_extrait_chaque_champ_de_stats(self, champ, attendu):
+ assert parse_band_page(page(FULL_STATS))[champ] == attendu
+
+ def test_themes_est_un_alias_de_lyrical_themes(self):
+ """upsert_band_enriched() lit data['themes'] : l'alias doit suivre."""
+ out = parse_band_page(page(FULL_STATS))
+ assert out["themes"] == out["lyrical_themes"] == "Death, Satanism, Darkness"
+
+ # Metal Archives n'est pas constant sur ce libellé : les trois orthographes
+ # ont été observées. En rater une revient à perdre les thèmes en silence.
+ @pytest.mark.parametrize("libelle", ["Lyrical themes", "Lyrical Themes", "Themes"])
+ def test_accepte_les_variantes_du_libelle_themes(self, libelle):
+ out = parse_band_page(page([(libelle, "War")]))
+ assert out["themes"] == "War"
+
+ @pytest.mark.parametrize("libelle", ["Current label", "Last label", "Label"])
+ def test_accepte_les_variantes_du_libelle_label(self, libelle):
+ assert parse_band_page(page([(libelle, "Peaceville")]))["label"] == "Peaceville"
+
+ @pytest.mark.parametrize("libelle", ["Formed in", "Formed"])
+ def test_accepte_les_variantes_de_formed(self, libelle):
+ assert parse_band_page(page([(libelle, "1991")]))["formed_in"] == "1991"
+
+ def test_info_raw_conserve_tout_en_minuscules(self):
+ raw = parse_band_page(page(FULL_STATS))["info_raw"]
+ assert raw["country of origin"] == "Norway"
+ assert all(k == k.lower() for k in raw)
+
+
+class TestRobustesse:
+ """Le parseur ne doit jamais lever : une page inattendue donne des champs vides."""
+
+ @pytest.mark.parametrize("html", [
+ "", "", "",
+ "pas du html du tout", "fragment
",
+ "Sans stats
",
+ ])
+ def test_ne_leve_jamais_sur_une_page_degradee(self, html):
+ out = parse_band_page(html)
+ assert isinstance(out, dict)
+ assert "name" in out and "lineup" in out
+
+ def test_champs_absents_valent_none(self):
+ out = parse_band_page(page([]))
+ for champ in ("status", "genre", "formed_in", "location", "label", "themes"):
+ assert out[champ] is None, champ
+
+ def test_page_sans_h1_ne_plante_pas(self):
+ assert parse_band_page("")["name"] == ""
+
+ def test_dt_sans_dd_est_ignore(self):
+ html = ""
+ assert parse_band_page(html)["genre"] is None
+
+ def test_retombe_sur_band_info_si_band_stats_absent(self):
+ html = """"""
+ assert parse_band_page(html)["genre"] == "Doom"
+
+ def test_la_structure_de_sortie_est_toujours_complete(self):
+ """upsert_band_enriched lit ces clés sans les tester : elles doivent exister."""
+ out = parse_band_page("")
+ for cle in ("name", "info_raw", "status", "genre", "formed_in", "location",
+ "themes", "lyrical_themes", "label", "lineup", "links",
+ "discography_url"):
+ assert cle in out, cle
+ assert isinstance(out["lineup"], dict)
+ assert isinstance(out["links"], list)
+
+
+LINEUP_HTML = """
+Current lineup
+
+Past members
+"""
+
+
+class TestLineup:
+ def test_regroupe_par_section(self):
+ lineup = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]
+ assert [m["name"] for m in lineup["current"]] == ["Necrobutcher", "Hellhammer"]
+ assert [m["name"] for m in lineup["past"]] == ["Dead"]
+
+ def test_conserve_role_et_url(self):
+ m = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]["current"][0]
+ assert m["role"] == "Bass"
+ assert m["url"] == "/artists/Necrobutcher/12"
+
+ def test_ignore_la_ligne_den_tete(self):
+ lineup = parse_band_page(page(FULL_STATS, LINEUP_HTML))["lineup"]
+ assert all(m["name"] not in ("Nom", "Rôle") for m in lineup["current"])
+
+ def test_ignore_les_lignes_sans_lien_artiste(self):
+ html = """Current
"""
+ assert parse_band_page(page(FULL_STATS, html))["lineup"]["current"] == []
+
+ def test_section_inconnue_retombe_sur_current(self):
+ html = """Quelque chose d'imprévu
"""
+ assert len(parse_band_page(page(FULL_STATS, html))["lineup"]["current"]) == 1
+
+ def test_les_quatre_sections_existent_meme_vides(self):
+ lineup = parse_band_page(page(FULL_STATS))["lineup"]
+ assert set(lineup) >= {"current", "past", "live", "session"}
+
+
+class TestLiensEtDates:
+ def test_extrait_lurl_de_discographie(self):
+ extra = 'Complete discography'
+ assert parse_band_page(page(FULL_STATS, extra=extra))["discography_url"] == "/band/discography/id/67"
+
+ def test_discographie_absente_vaut_none(self):
+ assert parse_band_page(page(FULL_STATS))["discography_url"] is None
+
+ def test_extrait_les_liens_externes(self):
+ extra = ''
+ links = parse_band_page(page(FULL_STATS, extra=extra))["links"]
+ assert links == [{"text": "Site officiel", "url": "https://x.example"}]
+
+ def test_extrait_les_dates_de_laudit_trail(self):
+ extra = ('Added on: 2003-05-12 14:02 '
+ 'Last modified on: 2026-08-01 09:31
')
+ out = parse_band_page(page(FULL_STATS, extra=extra))
+ assert out["ma_created_at"] == "2003-05-12 14:02"
+ assert out["ma_modified_at"] == "2026-08-01 09:31"
+
+ def test_audit_trail_absent_nintroduit_pas_les_cles(self):
+ out = parse_band_page(page(FULL_STATS))
+ assert out.get("ma_created_at") is None
+
+
+class TestPageHash:
+ def test_stable_et_sensible(self):
+ assert page_hash("a") == page_hash("a")
+ assert page_hash("a") != page_hash("b")
+
+ def test_correspond_a_md5(self):
+ # upsert_band_enriched compare ce hash pour éviter les écritures inutiles :
+ # changer d'algorithme invaliderait tout le cache d'un coup.
+ assert page_hash("x") == hashlib.md5(b"x").hexdigest()
+
+ def test_accepte_lunicode(self):
+ assert len(page_hash("Mötley Crüe — ✝")) == 32
diff --git a/apps/geocoder/src/parser.py b/apps/geocoder/src/parser.py
index 300e8af..ef0967d 100644
--- a/apps/geocoder/src/parser.py
+++ b/apps/geocoder/src/parser.py
@@ -138,7 +138,14 @@ def parse_location_text(location_text: str, band_country: str | None = None) ->
step_order += 1
continue
- cities = [c.strip() for c in _CITY_SPLIT.split(raw) if c.strip()]
+ # Déduplication à l'intérieur d'une étape : "Oslo/Oslo" produisait deux
+ # fois la même ligne, alors que band_locations impose
+ # UNIQUE (ma_id, step_order, location_raw). Le doublon était absorbé par
+ # le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les
+ # compteurs et n'a aucun sens métier — une étape ne se déroule pas deux
+ # fois au même endroit. Trouvé par un test de propriété (contre-exemple
+ # minimal : "0/0"). dict.fromkeys préserve l'ordre d'apparition.
+ cities = list(dict.fromkeys(c.strip() for c in _CITY_SPLIT.split(raw) if c.strip()))
any_added = False
for city in cities:
diff --git a/apps/geocoder/tests/test_parser.py b/apps/geocoder/tests/test_parser.py
index e359f80..8abaa64 100644
--- a/apps/geocoder/tests/test_parser.py
+++ b/apps/geocoder/tests/test_parser.py
@@ -64,6 +64,18 @@ class TestParseLocationText:
rows = parse_location_text("fr")
assert rows[0]["is_country_only"] is False
+ # Régression : "Oslo/Oslo" produisait deux lignes identiques, que la
+ # contrainte UNIQUE de band_locations aurait rejetées. Contre-exemple
+ # trouvé par test_parser_property.py.
+ def test_une_ville_repetee_dans_une_etape_ne_donne_quune_ligne(self):
+ rows = parse_location_text("Oslo/Oslo")
+ assert len(rows) == 1
+ assert rows[0]["location_raw"] == "Oslo"
+
+ def test_lordre_dapparition_est_preserve_apres_deduplication(self):
+ rows = parse_location_text("Bergen/Oslo/Bergen")
+ assert [r["location_raw"] for r in rows] == ["Bergen", "Oslo"]
+
def test_plusieurs_villes_separees(self):
"""Une même étape peut lister plusieurs villes : chacune devient une ligne."""
rows = parse_location_text("Oslo/Bergen")
diff --git a/apps/geocoder/tests/test_parser_property.py b/apps/geocoder/tests/test_parser_property.py
new file mode 100644
index 0000000..4094a96
--- /dev/null
+++ b/apps/geocoder/tests/test_parser_property.py
@@ -0,0 +1,146 @@
+"""
+Propriétés du parseur de localisations.
+
+Ce module décide combien de lignes `band_locations` sont créées, donc combien
+d'appels Geoapify et Groq — facturés — seront déclenchés. Les tests par
+l'exemple ne couvrent que les formats qu'on a vus ; Hypothesis explore les
+autres, en particulier ce que Metal Archives contient réellement : parenthèses
+déséquilibrées, points-virgules en série, unicode, chaînes très longues.
+
+L'invariant qui compte : quelle que soit l'entrée, la fonction rend une liste
+de lignes bien formées ou une liste vide — jamais d'exception, jamais une clé
+manquante. Une exception ici interromprait l'enqueue de toute la file.
+
+max_examples est fixé à 150 : `npm run check` tourne des dizaines de fois par
+jour et ces tests en constituaient le chemin critique. 150 tirages ont suffi à
+trouver le contre-exemple « 0/0 » (villes dupliquées dans une même étape) ;
+au-delà, on paie sans rien apprendre de plus. Augmenter ponctuellement pour
+une chasse ciblée.
+"""
+
+from hypothesis import given, settings
+from hypothesis import strategies as st
+from src.parser import build_fallback_queries, country_centroid, parse_location_text
+
+# Caractères que MA utilise réellement comme séparateurs ou décorations.
+LOCATION_TEXT = st.text(
+ alphabet=st.sampled_from(
+ list("abcdefghijklmnopqrstuvwxyzÀÉÖøå ,;/()-.'0123456789") + ["\t", "\n"]
+ ),
+ max_size=120,
+)
+
+COUNTRY = st.one_of(
+ st.sampled_from(["FR", "NO", "DE", "SE", "GB", "ZZ", "fr", ""]),
+ st.none(),
+ st.text(max_size=5),
+)
+
+
+class TestParseLocationTextTotalite:
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_ne_leve_jamais_et_rend_toujours_une_liste(self, texte):
+ assert isinstance(parse_location_text(texte), list)
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_chaque_ligne_a_exactement_les_cles_attendues(self, texte):
+ # db.py insère ces clés sans les vérifier : une clé manquante ferait
+ # échouer l'enqueue au milieu du lot.
+ for row in parse_location_text(texte):
+ assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"}
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_les_types_de_chaque_champ_sont_stables(self, texte):
+ for row in parse_location_text(texte):
+ assert isinstance(row["step_order"], int)
+ assert isinstance(row["is_country_only"], bool)
+ assert isinstance(row["location_raw"], str)
+ assert row["step_label"] is None or isinstance(row["step_label"], str)
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_aucun_lieu_vide_nest_produit(self, texte):
+ # Un lieu vide partirait en géocodage et consommerait un appel facturé
+ # pour rien.
+ for row in parse_location_text(texte):
+ assert row["location_raw"].strip() != ""
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_les_step_order_sont_positifs_et_croissants(self, texte):
+ orders = [r["step_order"] for r in parse_location_text(texte)]
+ assert all(o >= 0 for o in orders)
+ assert orders == sorted(orders)
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT)
+ def test_la_contrainte_dunicite_de_la_base_est_respectee(self, texte):
+ # band_locations a UNIQUE (ma_id, step_order, location_raw) : deux lignes
+ # identiques dans un même lot feraient échouer l'insertion.
+ rows = parse_location_text(texte)
+ cles = [(r["step_order"], r["location_raw"]) for r in rows]
+ assert len(cles) == len(set(cles))
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(max_size=400))
+ def test_supporte_du_texte_totalement_arbitraire(self, texte):
+ assert isinstance(parse_location_text(texte), list)
+
+ @settings(max_examples=150, deadline=None)
+ @given(LOCATION_TEXT, COUNTRY)
+ def test_le_pays_du_groupe_ne_casse_rien(self, texte, pays):
+ assert isinstance(parse_location_text(texte, pays), list)
+
+
+class TestBuildFallbackQueriesProprietes:
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(min_size=1, max_size=100), COUNTRY)
+ def test_ne_leve_jamais_et_rend_une_liste_de_chaines(self, brut, pays):
+ out = build_fallback_queries(brut, pays)
+ assert isinstance(out, list)
+ assert all(isinstance(q, str) for q in out)
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(min_size=1, max_size=100), COUNTRY)
+ def test_aucun_doublon(self, brut, pays):
+ # Chaque requête est un appel Geoapify facturé : un doublon, c'est de
+ # l'argent jeté.
+ out = build_fallback_queries(brut, pays)
+ assert len(out) == len(set(out))
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(min_size=1, max_size=100), COUNTRY)
+ def test_aucune_requete_vide(self, brut, pays):
+ assert all(q.strip() != "" for q in build_fallback_queries(brut, pays))
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(min_size=1, max_size=60), COUNTRY)
+ def test_le_nombre_de_requetes_reste_borne(self, brut, pays):
+ # Le repli découpe sur les virgules : sans borne, une entrée pathologique
+ # générerait des dizaines d'appels facturés pour un seul lieu.
+ n_virgules = brut.count(",")
+ assert len(build_fallback_queries(brut, pays)) <= 2 * (n_virgules + 2) + 2
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(alphabet=st.characters(blacklist_categories=("Cs",)), min_size=1, max_size=50))
+ def test_supporte_lunicode_arbitraire(self, brut):
+ assert isinstance(build_fallback_queries(brut, "FR"), list)
+
+
+class TestCountryCentroidProprietes:
+ @settings(max_examples=150, deadline=None)
+ @given(st.text(max_size=10))
+ def test_rend_none_ou_des_coordonnees_valides(self, code):
+ r = country_centroid(code)
+ if r is not None:
+ lat, lon = r
+ assert -90 <= lat <= 90
+ assert -180 <= lon <= 180
+
+ @settings(max_examples=150, deadline=None)
+ @given(st.sampled_from(["FR", "NO", "DE", "SE", "IT", "ES", "PL"]))
+ def test_insensible_a_la_casse_et_aux_espaces(self, code):
+ assert country_centroid(code) == country_centroid(f" {code.lower()} ")
diff --git a/apps/web/test/pure.property.test.js b/apps/web/test/pure.property.test.js
new file mode 100644
index 0000000..4559deb
--- /dev/null
+++ b/apps/web/test/pure.property.test.js
@@ -0,0 +1,195 @@
+import { describe, it, expect } from "vitest";
+import fc from "fast-check";
+import fs from "node:fs";
+import path from "node:path";
+import vm from "node:vm";
+import { fileURLToPath } from "node:url";
+
+/**
+ * Propriétés de la logique de filtrage du site public.
+ *
+ * Ces fonctions décident ce que l'utilisateur voit sur la carte. Un cas limite
+ * mal traité ne produit pas d'erreur : il fait simplement disparaître des
+ * groupes, ou en affiche qui auraient dû être filtrés. C'est invisible en
+ * production, d'où l'intérêt de chercher les contre-exemples plutôt que
+ * d'énumérer les cas auxquels on a pensé.
+ */
+const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../site");
+const sandbox = { module: undefined };
+vm.createContext(sandbox);
+vm.runInContext(fs.readFileSync(path.join(SITE, "pure.js"), "utf8"), sandbox);
+const P = sandbox.BMPure;
+
+const anyValue = fc.oneof(
+ fc.string(), fc.integer(), fc.double(), fc.boolean(),
+ fc.constant(null), fc.constant(undefined), fc.constant(""),
+);
+
+const band = fc.record({
+ ma_id: fc.integer({ min: 0 }),
+ name: fc.option(fc.string(), { nil: undefined }),
+ genre: fc.option(fc.string(), { nil: undefined }),
+ status: fc.option(fc.string(), { nil: undefined }),
+ country: fc.option(fc.string(), { nil: undefined }),
+ location_text: fc.option(fc.string(), { nil: undefined }),
+ themes: fc.option(fc.array(fc.string(), { maxLength: 5 }), { nil: undefined }),
+ formed_year: fc.option(fc.integer({ min: 1800, max: 2100 }), { nil: null }),
+});
+
+describe("échappement HTML", () => {
+ // Tout le rendu passe par innerHTML : un échappement incomplet est une
+ // faille XSS, pas un défaut cosmétique.
+ it("aucun caractère dangereux ne survit, quelle que soit l'entrée", () => {
+ fc.assert(fc.property(anyValue, (v) => {
+ const out = P.escapeHtml(v);
+ expect(out).not.toMatch(/[<>]/);
+ expect(out).not.toMatch(/["']/);
+ }), { numRuns: 600 });
+ });
+
+ it("est idempotent en sûreté : ré-échapper ne réintroduit rien", () => {
+ fc.assert(fc.property(fc.string(), (v) => {
+ expect(P.escapeHtml(P.escapeHtml(v))).not.toMatch(/[<>"']/);
+ }), { numRuns: 400 });
+ });
+
+ it("préserve le texte sans caractère spécial", () => {
+ fc.assert(fc.property(
+ fc.string({ unit: fc.constantFrom(..."abcdéèêöµ0123456789 -_.") }),
+ (v) => { expect(P.escapeHtml(v)).toBe(v); }
+ ), { numRuns: 300 });
+ });
+});
+
+describe("normalisation", () => {
+ it("norm ne lève jamais et rend toujours une chaîne trimée", () => {
+ fc.assert(fc.property(anyValue, (v) => {
+ const r = P.norm(v);
+ expect(typeof r).toBe("string");
+ expect(r).toBe(r.trim());
+ }), { numRuns: 500 });
+ });
+
+ it("parseYear rend null ou un nombre fini", () => {
+ fc.assert(fc.property(anyValue, (v) => {
+ const r = P.parseYear(v);
+ expect(r === null || Number.isFinite(r)).toBe(true);
+ }), { numRuns: 500 });
+ });
+
+ it("splitThemes rend toujours un tableau de chaînes non vides", () => {
+ fc.assert(fc.property(
+ fc.oneof(fc.string(), fc.array(fc.string(), { maxLength: 8 }), fc.constant(null)),
+ (v) => {
+ const r = P.splitThemes(v);
+ expect(Array.isArray(r)).toBe(true);
+ expect(r.every((x) => typeof x === "string" && x.length > 0)).toBe(true);
+ }
+ ), { numRuns: 500 });
+ });
+});
+
+describe("filtrage", () => {
+ it("matchesQuery ne lève jamais et rend un booléen", () => {
+ fc.assert(fc.property(band, fc.string(), (b, q) => {
+ expect(typeof P.matchesQuery(b, q)).toBe("boolean");
+ }), { numRuns: 500 });
+ });
+
+ // Propriété structurante de la recherche : une requête vide n'exclut rien.
+ it("une requête vide laisse toujours passer", () => {
+ fc.assert(fc.property(band, (b) => {
+ expect(P.matchesQuery(b, "")).toBe(true);
+ }), { numRuns: 300 });
+ });
+
+ it("la recherche est insensible à la casse", () => {
+ fc.assert(fc.property(band, fc.string({ minLength: 1 }), (b, q) => {
+ expect(P.matchesQuery(b, q.toLowerCase()))
+ .toBe(P.matchesQuery({ ...b }, q.toLowerCase()));
+ }), { numRuns: 300 });
+ });
+
+ // Invariant tri-état : une facette ABSENTE de la Map ne doit jamais exclure.
+ // C'est ce qui évite de vider la carte quand le jeu de données change.
+ it("une facette inconnue laisse toujours passer", () => {
+ fc.assert(fc.property(fc.string(), fc.string(), (val, fallback) => {
+ expect(P.matchesFacet(val, new Map(), fallback)).toBe(true);
+ }), { numRuns: 400 });
+ });
+
+ it("matchesFacet suit exactement la valeur cochée", () => {
+ fc.assert(fc.property(
+ fc.string({ minLength: 1 }).filter((s) => s.trim() !== ""),
+ fc.boolean(),
+ (val, coche) => {
+ const map = new Map([[val.trim(), coche]]);
+ expect(P.matchesFacet(val, map, "??")).toBe(coche);
+ }
+ ), { numRuns: 400 });
+ });
+
+ it("matchesYear ne lève jamais et rend un booléen", () => {
+ fc.assert(fc.property(
+ band,
+ fc.record({ min: fc.option(fc.integer(), { nil: null }), max: fc.option(fc.integer(), { nil: null }) }),
+ fc.record({ min: fc.option(fc.integer(), { nil: null }), max: fc.option(fc.integer(), { nil: null }) }),
+ (b, filtre, plage) => {
+ expect(typeof P.matchesYear(b, filtre, plage)).toBe("boolean");
+ }
+ ), { numRuns: 500 });
+ });
+
+ // Sans cette propriété, ouvrir le site avec le curseur au maximum ferait
+ // disparaître tous les groupes sans année.
+ it("une plage non resserrée laisse tout passer, année ou pas", () => {
+ fc.assert(fc.property(band, fc.integer(), fc.integer(), (b, a, z) => {
+ const [min, max] = a <= z ? [a, z] : [z, a];
+ expect(P.matchesYear(b, { min, max }, { min, max })).toBe(true);
+ }), { numRuns: 400 });
+ });
+});
+
+describe("tri", () => {
+ it("préserve toujours le nombre d'éléments et n'altère pas l'entrée", () => {
+ fc.assert(fc.property(
+ fc.array(band, { maxLength: 30 }),
+ fc.constantFrom("az", "status", "genre", "country", "year", "inconnu"),
+ (bands, mode) => {
+ const copie = JSON.parse(JSON.stringify(bands));
+ const out = P.sortBands(bands, mode);
+ expect(out).toHaveLength(bands.length);
+ expect(bands).toEqual(copie); // pas d'effet de bord
+ expect(new Set(out)).toEqual(new Set(bands)); // pas de perte
+ }
+ ), { numRuns: 300 });
+ });
+
+ it("le tri par année est décroissant, les groupes sans année en dernier", () => {
+ fc.assert(fc.property(fc.array(band, { maxLength: 25 }), (bands) => {
+ const out = P.sortBands(bands, "year");
+ const clefs = out.map((b) => (Number.isFinite(b.formed_year) ? b.formed_year : -1));
+ for (let i = 1; i < clefs.length; i++) {
+ expect(clefs[i - 1]).toBeGreaterThanOrEqual(clefs[i]);
+ }
+ }), { numRuns: 300 });
+ });
+});
+
+describe("clé de coordonnées", () => {
+ it("deux coordonnées égales donnent toujours la même clé", () => {
+ fc.assert(fc.property(fc.double({ noNaN: true }), fc.double({ noNaN: true }), (lat, lon) => {
+ expect(P.keyFromLatLon(lat, lon)).toBe(P.keyFromLatLon(String(lat), String(lon)));
+ }), { numRuns: 400 });
+ });
+
+ it("la clé a toujours la forme attendue", () => {
+ fc.assert(fc.property(
+ fc.double({ min: -90, max: 90, noNaN: true }),
+ fc.double({ min: -180, max: 180, noNaN: true }),
+ (lat, lon) => {
+ expect(P.keyFromLatLon(lat, lon)).toMatch(/^-?\d+\.\d{6},-?\d+\.\d{6}$/);
+ }
+ ), { numRuns: 400 });
+ });
+});
diff --git a/pyproject.toml b/pyproject.toml
index 986ebfe..d9464f9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,8 +30,10 @@ ignore = [
"apps/crawler/src/polite.py" = ["S311"]
"apps/geocoder/src/worker.py" = ["S311", "S608"]
# md5 sert d'empreinte de contenu pour détecter les pages inchangées,
-# jamais de primitive de sécurité.
+# jamais de primitive de sécurité. Le test compare explicitement à md5 pour
+# verrouiller ce choix (changer d'algorithme invaliderait tout le cache).
"apps/crawler/src/scraper_band.py" = ["S324"]
+"apps/crawler/tests/test_scraper_band.py" = ["S324"]
[tool.pytest.ini_options]
# Chaque app est testée depuis sa propre racine (`rootdir`), d'où le chemin relatif
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 167ee11..5198dc5 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -7,3 +7,6 @@ pip-audit==2.7.3
# Les tests importent src.db, qui importe psycopg2 au chargement du module.
# Aucune connexion n'est ouverte : get_conn est remplacé dans les tests.
psycopg2-binary==2.9.9
+beautifulsoup4==4.12.3
+lxml==5.3.0
+hypothesis==6.122.3