""" Couche réseau du crawler : FlareSolverr et le client Metal Archives. Aucune requête réelle n'est émise — le transport est remplacé par un double. Ce qui est testé, c'est la LOGIQUE autour du réseau : quand réessayer, quand abandonner, comment extraire les données d'une réponse enveloppée par FlareSolverr. C'est là que sont les bugs, pas dans `requests.get`. Enjeu concret : ces retours conditionnent le nombre de requêtes envoyées à Metal Archives. Une boucle de retry mal bornée nous ferait bannir. """ import json import time from unittest.mock import Mock import pytest from src import ma_http from src.flaresolverr import FlareSolverr from src.ma_http import ( MASession, _build_url, _clean, _extract_json, _extract_link, _extract_ma_id, _parse_archive_row, _parse_list_row, ) # ------------------------------------------------------------------ # Helpers purs # ------------------------------------------------------------------ class TestBuildUrl: def test_sans_parametre(self): assert _build_url("http://x/y") == "http://x/y" def test_encode_les_parametres(self): assert _build_url("http://x", {"a": "1", "b": "deux mots"}) == "http://x?a=1&b=deux+mots" def test_parametres_vides_ne_changent_rien(self): assert _build_url("http://x", {}) == "http://x" class TestExtractJson: """FlareSolverr enveloppe le JSON dans
 en échappant les chevrons."""

    def test_extrait_depuis_pre(self):
        assert _extract_json('
{"a": 1}
') == {"a": 1} def test_deshechappe_les_entites_html(self): # MA renvoie du HTML dans les champs JSON ; FlareSolverr échappe les # chevrons de la page entière. Sans unescape, les fragments arrivent # sous forme <a> et _extract_link n'y trouve plus de href. body = "
{\"h\": \"<a>T</a>\"}
" assert _extract_json(body)["h"] == "
T" def test_accepte_un_json_nu(self): assert _extract_json('{"a": 2}') == {"a": 2} def test_json_invalide_leve(self): with pytest.raises(json.JSONDecodeError): _extract_json("
pas du json
") def test_prend_le_premier_bloc_pre(self): assert _extract_json('
{"a":1}
{"b":2}
') == {"a": 1} class TestExtractLink: @pytest.mark.parametrize("frag,href,text", [ ('Mayhem', "/bands/Mayhem/67", "Mayhem"), ("Y", "/x", "Y"), ("texte sans lien", None, "texte sans lien"), ("", None, ""), (None, None, ""), ]) def test_extrait_href_et_texte(self, frag, href, text): assert _extract_link(frag) == (href, text) def test_retire_les_balises_imbriquees(self): _, text = _extract_link('Gras suite') assert "<" not in text class TestExtractMaId: @pytest.mark.parametrize("url,attendu", [ ("/bands/Mayhem/67", 67), ("https://www.metal-archives.com/bands/Darkthrone/146", 146), # ma_id dépasse 2^31 dans les données réelles : la colonne est BIGINT. ("/bands/X/3500000000", 3500000000), ("/bands/SansId", None), ("", None), (None, None), ]) def test_extrait_lidentifiant(self, url, attendu): assert _extract_ma_id(url) == attendu class TestClean: @pytest.mark.parametrize("brut,attendu", [ ("Black Metal", "Black Metal"), (" espaces ", "espaces"), (None, ""), ("", ""), (123, "123"), ]) def test_nettoie(self, brut, attendu): assert _clean(brut) == attendu # ------------------------------------------------------------------ # Parseurs de lignes # ------------------------------------------------------------------ class TestParseListRow: ROW = ['Mayhem', "Black Metal", "Oslo", "Active"] def test_ligne_complete(self): out = _parse_list_row(self.ROW, "NO") assert out == { "ma_id": 67, "name": "Mayhem", "url": "/bands/Mayhem/67", "country": "NO", "genre": "Black Metal", "location_text": "Oslo", "status": "Active", } def test_statut_optionnel(self): assert _parse_list_row(self.ROW[:3], "NO")["status"] is None @pytest.mark.parametrize("row", [ [], # vide ["Y", "g"], # trop courte ["pas de lien", "g", "l"], # sans href → pas d'id ['X', "g", "l"], # id non numérique ['', "g", "l"], # nom vide ]) def test_ligne_inexploitable_renvoie_none(self, row): # Renvoyer None plutôt que lever : une ligne malformée ne doit pas # interrompre le crawl des 800 autres. assert _parse_list_row(row, "NO") is None class TestParseArchiveRow: ROW = ["June 1", 'Foo', 'Germany', "Black Metal", "Jun 1st", "u"] def test_ligne_complete(self): out = _parse_archive_row(self.ROW, "created") assert out["ma_id"] == 123 assert out["name"] == "Foo" assert out["country"] == "DE" assert out["genre"] == "Black Metal" def test_le_pays_vient_du_lien_de_liste(self): assert _parse_archive_row(self.ROW, "created")["country"] == "DE" def test_sans_lien_pays_retombe_sur_le_texte(self): row = list(self.ROW) row[2] = "Germany" assert _parse_archive_row(row, "created")["country"] == "Germany" # Le format MA n'inclut pas l'année : le filtre par date est désactivé et # on re-lit les ~800 dernières entrées à chaque run. Verrouillé ici pour # qu'une « optimisation » ne réintroduise pas un filtrage faux. def test_date_str_est_toujours_none(self): assert _parse_archive_row(self.ROW, "created")["date_str"] is None @pytest.mark.parametrize("row", [[], ["a", "b", "c"], ["a", "sans lien", "c", "d"]]) def test_ligne_inexploitable_renvoie_none(self, row): assert _parse_archive_row(row, "created") is None # ------------------------------------------------------------------ # FlareSolverr # ------------------------------------------------------------------ def fake_fs(response_json, status_code=200): fs = FlareSolverr("http://fs:8191") resp = Mock(status_code=status_code) resp.json.return_value = response_json resp.raise_for_status = Mock() fs._call = Mock(return_value=response_json) return fs class TestFlareSolverr: def test_get_renvoie_statut_et_corps(self): fs = fake_fs({"solution": {"status": 200, "response": "ok"}}) assert fs.get("http://x") == (200, "ok") def test_solution_absente_ne_leve_pas(self): # FlareSolverr renvoie parfois une enveloppe sans solution : on veut un # statut 0 exploitable par la logique de retry, pas une exception. fs = fake_fs({}) status, body = fs.get("http://x") assert status == 0 def test_transmet_lidentifiant_de_session(self): fs = fake_fs({"solution": {"status": 200, "response": ""}}) fs.get("http://x", session_id="sess-1") payload = fs._call.call_args[0][0] assert payload.get("session") == "sess-1" def test_healthy_reflete_la_reponse(self): fs = fake_fs({"status": "ok"}) assert fs.healthy() in (True, False) # ne doit jamais lever # ------------------------------------------------------------------ # MASession : logique de réessai # ------------------------------------------------------------------ class FakeFS: """Rejoue une séquence de (status, body) pour les vraies requêtes. ensure_session() effectue un GET de préchauffage (cookies Cloudflare) à chaque création ou rafraîchissement de session. Ces appels sont comptés séparément : les mélanger aux vraies requêtes rendait tous les comptages incompréhensibles. """ WARMUP = ma_http._WARMUP_URL def __init__(self, sequence): self.sequence = list(sequence) self.calls = 0 # requêtes utiles uniquement self.warmups = 0 self.sessions_created = 0 def create_session(self): self.sessions_created += 1 return f"sess-{self.sessions_created}" def destroy_session(self, sid): pass def get(self, url, session_id=None): if url == self.WARMUP: self.warmups += 1 return 200, "" self.calls += 1 return self.sequence[min(self.calls - 1, len(self.sequence) - 1)] @pytest.fixture(autouse=True) def _no_sleep(monkeypatch): """Neutralise les temporisations de politesse : les tests doivent être instantanés.""" monkeypatch.setattr(ma_http, "sleep_range", lambda *a, **k: None) class TestMASessionRetry: def _session(self, sequence): fs = FakeFS(sequence) s = MASession(fs) # Session déjà établie : on veut compter les requêtes utiles, pas la # création initiale. L'attribut est privé côté implémentation. s._session_id = "sess-0" s._session_age = time.time() return s, fs def test_reussite_immediate(self): s, fs = self._session([(200, '
{"a":1}
')]) assert s.get_json("http://x") == {"a": 1} assert fs.calls == 1 @pytest.mark.parametrize("status", [403, 429, 503]) def test_reessaie_sur_blocage_puis_reussit(self, status): s, fs = self._session([(status, ""), (200, '
{"a":1}
')]) assert s.get_json("http://x") == {"a": 1} assert fs.calls == 2 # Un blocage justifie une session Chrome neuve, pas un simple retry : # les cookies Cloudflare sont probablement grillés. assert fs.sessions_created >= 1 assert fs.warmups >= 1 @pytest.mark.parametrize("status", [403, 429, 503]) def test_abandonne_apres_le_dernier_essai(self, status): s, fs = self._session([(status, "")]) with pytest.raises(RuntimeError, match=f"HTTP {status}"): s.get_json("http://x", retries=2) # Borne stricte : retries=2 → 3 tentatives, jamais plus. Une boucle non # bornée nous ferait bannir de Metal Archives. assert fs.calls == 3 def test_un_statut_non_bloquant_nest_pas_reessaye(self): # 500 n'est pas un blocage anti-bot : réessayer ne servirait qu'à # marteler un serveur déjà en difficulté. s, fs = self._session([(500, "")]) with pytest.raises(RuntimeError, match="HTTP 500"): s.get_json("http://x", retries=2) assert fs.calls == 1 def test_json_invalide_est_reessaye_puis_leve(self): s, fs = self._session([(200, "
pas du json
")]) with pytest.raises(RuntimeError, match="JSON parse error"): s.get_json("http://x", retries=1) assert fs.calls == 2 def test_json_invalide_puis_valide(self): s, fs = self._session([(200, "
invalide
"), (200, '
{"ok":1}
')]) assert s.get_json("http://x", retries=2) == {"ok": 1} def test_get_html_reessaie_puis_reussit(self): s, fs = self._session([(429, ""), (200, "ok")]) assert s.get_html("http://x") == "ok" assert fs.calls == 2 def test_get_html_abandonne_avec_le_bon_message(self): s, fs = self._session([(403, "")]) with pytest.raises(RuntimeError, match="HTTP 403"): s.get_html("http://x", retries=1) assert fs.calls == 2 def test_retries_zero_ne_fait_quun_appel(self): s, fs = self._session([(503, "")]) with pytest.raises(RuntimeError): s.get_json("http://x", retries=0) assert fs.calls == 1 def test_une_session_est_creee_si_absente(self): fs = FakeFS([(200, '
{"a":1}
')]) s = MASession(fs) s.get_json("http://x") assert fs.sessions_created == 1 # Le préchauffage récupère les cookies Cloudflare avant toute requête # utile : sans lui, la première tomberait systématiquement en 403. assert fs.warmups == 1