metalfrom.eu/apps/crawler/tests/test_network_io.py
Nicolas Fryder 83ea8030e7
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
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>
2026-08-18 22:09:37 +02:00

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