test: propriétés généralisées, scraper HTML et couche réseau couverts
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run

BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. 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.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.

SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.

COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. 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.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.

PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
  centroïdes. Invariants — jamais d'exception, structure toujours complète,
  aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
  cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
  sans effet de bord ni perte d'éléments, clés de coordonnées.

max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».

Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nicolas Fryder 2026-08-18 22:09:37 +02:00
parent 43d6527172
commit 83ea8030e7
8 changed files with 895 additions and 2 deletions

View file

@ -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 <pre> en échappant les chevrons."""
def test_extrait_depuis_pre(self):
assert _extract_json('<html><pre>{"a": 1}</pre></html>') == {"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 <a> arrivent
# sous forme &lt;a&gt; et _extract_link n'y trouve plus de href.
body = "<pre>{\"h\": \"&lt;a&gt;T&lt;/a&gt;\"}</pre>"
assert _extract_json(body)["h"] == "<a>T</a>"
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("<pre>pas du json</pre>")
def test_prend_le_premier_bloc_pre(self):
assert _extract_json('<pre>{"a":1}</pre><pre>{"b":2}</pre>') == {"a": 1}
class TestExtractLink:
@pytest.mark.parametrize("frag,href,text", [
('<a href="/bands/Mayhem/67">Mayhem</a>', "/bands/Mayhem/67", "Mayhem"),
("<a href='/x'>Y</a>", "/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('<a href="/x"><strong>Gras</strong> suite</a>')
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", [
("<b>Black</b> Metal", "Black Metal"),
(" espaces ", "espaces"),
(None, ""),
("", ""),
(123, "123"),
])
def test_nettoie(self, brut, attendu):
assert _clean(brut) == attendu
# ------------------------------------------------------------------
# Parseurs de lignes
# ------------------------------------------------------------------
class TestParseListRow:
ROW = ['<a href="/bands/Mayhem/67">Mayhem</a>', "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
["<a href='/x'>Y</a>", "g"], # trop courte
["pas de lien", "g", "l"], # sans href → pas d'id
['<a href="/bands/X/abc">X</a>', "g", "l"], # id non numérique
['<a href="/bands/X/1"></a>', "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", '<a href="/bands/Foo/123">Foo</a>',
'<a href="/lists/DE">Germany</a>', "Black Metal", "Jun 1st", "<a>u</a>"]
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": "<html>ok</html>"}})
assert fs.get("http://x") == (200, "<html>ok</html>")
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, '<pre>{"a":1}</pre>')])
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, '<pre>{"a":1}</pre>')])
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, "<pre>pas du json</pre>")])
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, "<pre>invalide</pre>"), (200, '<pre>{"ok":1}</pre>')])
assert s.get_json("http://x", retries=2) == {"ok": 1}
def test_get_html_reessaie_puis_reussit(self):
s, fs = self._session([(429, ""), (200, "<html>ok</html>")])
assert s.get_html("http://x") == "<html>ok</html>"
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, '<pre>{"a":1}</pre>')])
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

View file

@ -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"<dt>{k}:</dt><dd>{v}</dd>" for k, v in stats) if stats else ""
return f"""
<html><body>
<h1 class="band_name"><a href="/bands/Mayhem/67">Mayhem</a></h1>
<div id="band_stats"><dl>{rows}</dl></div>
{lineup}{extra}
</body></html>"""
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", [
"", "<html></html>", "<html><body></body></html>",
"pas du html du tout", "<div><span>fragment</span></div>",
"<html><body><h1>Sans stats</h1></body></html>",
])
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("<html><body><div id='band_stats'></div></body></html>")["name"] == ""
def test_dt_sans_dd_est_ignore(self):
html = "<html><body><div id='band_stats'><dl><dt>Genre:</dt></dl></div></body></html>"
assert parse_band_page(html)["genre"] is None
def test_retombe_sur_band_info_si_band_stats_absent(self):
html = """<html><body><div id="band_info"><dl>
<dt>Genre:</dt><dd>Doom</dd></dl></div></body></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 = """
<h2>Current lineup</h2>
<table class="lineupTable">
<tr><th>Nom</th><th>Rôle</th></tr>
<tr><td><a href="/artists/Necrobutcher/12">Necrobutcher</a></td><td>Bass</td></tr>
<tr><td><a href="/artists/Hellhammer/13">Hellhammer</a></td><td>Drums</td></tr>
</table>
<h2>Past members</h2>
<table class="lineupTable">
<tr><th>Nom</th><th>Rôle</th></tr>
<tr><td><a href="/artists/Dead/14">Dead</a></td><td>Vocals</td></tr>
</table>"""
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 = """<h2>Current</h2><table class="lineupTable">
<tr><th>x</th></tr><tr><td>Texte sans lien</td><td>Bass</td></tr></table>"""
assert parse_band_page(page(FULL_STATS, html))["lineup"]["current"] == []
def test_section_inconnue_retombe_sur_current(self):
html = """<h2>Quelque chose d'imprévu</h2><table class="lineupTable">
<tr><th>x</th></tr><tr><td><a href="/artists/X/1">X</a></td><td>Bass</td></tr></table>"""
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 = '<a href="/band/discography/id/67">Complete discography</a>'
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 = '<div id="band_links"><a href="https://x.example">Site officiel</a></div>'
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 = ('<div id="auditTrail">Added on: 2003-05-12 14:02 '
'Last modified on: 2026-08-01 09:31</div>')
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("<html>a</html>") == page_hash("<html>a</html>")
assert page_hash("<html>a</html>") != page_hash("<html>b</html>")
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

View file

@ -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:

View file

@ -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")

View file

@ -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()} ")

View file

@ -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 ê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 });
});
});

View file

@ -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

View file

@ -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