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

Mayhem

+
{rows}
+ {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 = "
Genre:
" + assert parse_band_page(html)["genre"] is None + + def test_retombe_sur_band_info_si_band_stats_absent(self): + html = """
+
Genre:
Doom
""" + 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

+ + + + +
NomRôle
NecrobutcherBass
HellhammerDrums
+

Past members

+ + + +
NomRôle
DeadVocals
""" + + +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

+
x
Texte sans lienBass
""" + assert parse_band_page(page(FULL_STATS, html))["lineup"]["current"] == [] + + def test_section_inconnue_retombe_sur_current(self): + html = """

Quelque chose d'imprévu

+
x
XBass
""" + 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