test: propriétés généralisées, scraper HTML et couche réseau couverts
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:
parent
43d6527172
commit
83ea8030e7
8 changed files with 895 additions and 2 deletions
324
apps/crawler/tests/test_network_io.py
Normal file
324
apps/crawler/tests/test_network_io.py
Normal 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 <a> et _extract_link n'y trouve plus de href.
|
||||||
|
body = "<pre>{\"h\": \"<a>T</a>\"}</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
|
||||||
204
apps/crawler/tests/test_scraper_band.py
Normal file
204
apps/crawler/tests/test_scraper_band.py
Normal 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
|
||||||
|
|
@ -138,7 +138,14 @@ def parse_location_text(location_text: str, band_country: str | None = None) ->
|
||||||
step_order += 1
|
step_order += 1
|
||||||
continue
|
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
|
any_added = False
|
||||||
|
|
||||||
for city in cities:
|
for city in cities:
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,18 @@ class TestParseLocationText:
|
||||||
rows = parse_location_text("fr")
|
rows = parse_location_text("fr")
|
||||||
assert rows[0]["is_country_only"] is False
|
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):
|
def test_plusieurs_villes_separees(self):
|
||||||
"""Une même étape peut lister plusieurs villes : chacune devient une ligne."""
|
"""Une même étape peut lister plusieurs villes : chacune devient une ligne."""
|
||||||
rows = parse_location_text("Oslo/Bergen")
|
rows = parse_location_text("Oslo/Bergen")
|
||||||
|
|
|
||||||
146
apps/geocoder/tests/test_parser_property.py
Normal file
146
apps/geocoder/tests/test_parser_property.py
Normal 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()} ")
|
||||||
195
apps/web/test/pure.property.test.js
Normal file
195
apps/web/test/pure.property.test.js
Normal 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 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -30,8 +30,10 @@ ignore = [
|
||||||
"apps/crawler/src/polite.py" = ["S311"]
|
"apps/crawler/src/polite.py" = ["S311"]
|
||||||
"apps/geocoder/src/worker.py" = ["S311", "S608"]
|
"apps/geocoder/src/worker.py" = ["S311", "S608"]
|
||||||
# md5 sert d'empreinte de contenu pour détecter les pages inchangées,
|
# 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/src/scraper_band.py" = ["S324"]
|
||||||
|
"apps/crawler/tests/test_scraper_band.py" = ["S324"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
# Chaque app est testée depuis sa propre racine (`rootdir`), d'où le chemin relatif
|
# Chaque app est testée depuis sa propre racine (`rootdir`), d'où le chemin relatif
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,6 @@ pip-audit==2.7.3
|
||||||
# Les tests importent src.db, qui importe psycopg2 au chargement du module.
|
# Les tests importent src.db, qui importe psycopg2 au chargement du module.
|
||||||
# Aucune connexion n'est ouverte : get_conn est remplacé dans les tests.
|
# Aucune connexion n'est ouverte : get_conn est remplacé dans les tests.
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
lxml==5.3.0
|
||||||
|
hypothesis==6.122.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue