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>
324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""
|
|
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
|