"""Тесты pravo_gov66_client — offline, без живой сети. Покрывают: парсинг search-HTML (doc_id/title/url), парсинг страницы документа (PDF-ссылка), fallback на ecp-вариант, отсутствие PDF, pacing/backoff (#2445 D4) между последовательными запросами к pravo.gov66.ru. """ from __future__ import annotations import httpx import pytest from app.services.scrapers.pravo_gov66_client import ( _LAW_TYPE_POSTANOVLENIE, _extract_pdf_url, _get_with_backoff, _parse_search_html, ) # ── Фикстура HTML страницы поиска ───────────────────────────────────────────── # Реальный фрагмент страницы https://www.pravo.gov66.ru/search/?q=изъятие+земельных+участков # Содержит ссылки вида /NNNNN/ с заголовком постановления. _SEARCH_HTML_FIXTURE = """
Скачать документ: Скачать PDF
Подписанная версия: Скачать подписанный PDF
""" # ── Фикстура HTML страницы документа только с ecp PDF ───────────────────────── _DOC_HTML_ECP_ONLY = """Текст постановления размещён ниже.
""" class TestLawTypeConstant: def test_postanovlenie_is_numeric_string(self) -> None: """Числовой код — сервер игнорирует текстовые значения типа «Постановление».""" assert _LAW_TYPE_POSTANOVLENIE == "1" assert _LAW_TYPE_POSTANOVLENIE.isdigit() class TestParseSearchHtml: def test_extracts_doc_ids_and_titles(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) ids = [r["doc_id"] for r in results] assert "35496" in ids assert "34812" in ids def test_ignores_non_numeric_paths(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) ids = [r["doc_id"] for r in results] assert "some-slug" not in ids def test_deduplicates_doc_ids(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) ids = [r["doc_id"] for r in results] assert ids.count("35496") == 1 def test_ignores_category_links(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) # /category/law/ не числовой id — не попадает. ids = [r["doc_id"] for r in results] assert len(ids) == 2 def test_url_is_absolute(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) for r in results: assert r["url"].startswith("https://www.pravo.gov66.ru/") def test_title_extracted(self) -> None: results = _parse_search_html(_SEARCH_HTML_FIXTURE) doc = next(r for r in results if r["doc_id"] == "35496") assert "изъятии" in doc["title"].lower() def test_empty_html_returns_empty(self) -> None: assert _parse_search_html("") == [] def test_html_no_doc_links_returns_empty(self) -> None: html = "Cat" assert _parse_search_html(html) == [] class TestExtractPdfUrl: def test_prefers_non_ecp_pdf(self) -> None: url = _extract_pdf_url(_DOC_HTML_WITH_PDF) assert url is not None assert "ecp" not in url assert url.endswith(".pdf") def test_non_ecp_pdf_is_absolute(self) -> None: url = _extract_pdf_url(_DOC_HTML_WITH_PDF) assert url is not None assert url.startswith("https://www.pravo.gov66.ru/media/pravo/") def test_falls_back_to_ecp_when_no_regular_pdf(self) -> None: url = _extract_pdf_url(_DOC_HTML_ECP_ONLY) assert url is not None assert "ecp" in url assert url.endswith(".pdf") def test_returns_none_when_no_pdf(self) -> None: url = _extract_pdf_url(_DOC_HTML_NO_PDF) assert url is None def test_ecp_fallback_is_absolute(self) -> None: url = _extract_pdf_url(_DOC_HTML_ECP_ONLY) assert url is not None assert url.startswith("https://www.pravo.gov66.ru/") # ── Тесты pacing/backoff (#2445 D4) — offline через httpx.MockTransport ─────── def _client_with_responses(responses: list[httpx.Response]) -> httpx.Client: """httpx.Client на MockTransport, отдающий responses по очереди (без реальной сети).""" calls = iter(responses) def _handler(request: httpx.Request) -> httpx.Response: return next(calls) return httpx.Client(transport=httpx.MockTransport(_handler)) class TestGetWithBackoff: def test_sleeps_before_request(self, monkeypatch: pytest.MonkeyPatch) -> None: """Пейсинг: time.sleep вызывается ПЕРЕД запросом, даже на первой/единственной попытке.""" sleeps: list[float] = [] monkeypatch.setattr( "app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: sleeps.append(s), ) client = _client_with_responses([httpx.Response(200, text="ok")]) _get_with_backoff(client, "https://www.pravo.gov66.ru/1/") assert len(sleeps) == 1 def test_paces_between_successive_fetches(self, monkeypatch: pytest.MonkeyPatch) -> None: """Несколько последовательных fetch → time.sleep между КАЖДЫМ (не только ретраи).""" sleeps: list[float] = [] monkeypatch.setattr( "app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: sleeps.append(s), ) client = _client_with_responses( [ httpx.Response(200, text="a"), httpx.Response(200, text="b"), httpx.Response(200, text="c"), ] ) for i in range(3): _get_with_backoff(client, f"https://www.pravo.gov66.ru/{i}/") assert len(sleeps) == 3 def test_retries_on_429_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None: """429 → backoff sleep + ретрай → 200 успешно, без исключения.""" sleeps: list[float] = [] monkeypatch.setattr( "app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: sleeps.append(s), ) client = _client_with_responses( [httpx.Response(429, text="rate limited"), httpx.Response(200, text="ok")] ) resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/") assert resp.status_code == 200 # 1 pacing-sleep перед 1й попыткой + 1 backoff-sleep перед ретраем + 1 pacing перед 2й. assert len(sleeps) >= 2 def test_retries_on_5xx_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None: sleeps: list[float] = [] monkeypatch.setattr( "app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: sleeps.append(s), ) client = _client_with_responses( [httpx.Response(503, text="unavailable"), httpx.Response(200, text="ok")] ) resp = _get_with_backoff(client, "https://www.pravo.gov66.ru/1/") assert resp.status_code == 200 def test_raises_after_exhausting_retries_on_429(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: None) # _MAX_RETRIES=2 → до 3 попыток, все 429. client = _client_with_responses([httpx.Response(429, text="x") for _ in range(3)]) with pytest.raises(httpx.HTTPStatusError): _get_with_backoff(client, "https://www.pravo.gov66.ru/1/") def test_4xx_other_than_429_raises_immediately_without_retry( self, monkeypatch: pytest.MonkeyPatch ) -> None: """404 не ретраится — сразу raise_for_status(), задача не тратит время на 3 попытки.""" sleeps: list[float] = [] monkeypatch.setattr( "app.services.scrapers.pravo_gov66_client.time.sleep", lambda s: sleeps.append(s), ) client = _client_with_responses([httpx.Response(404, text="not found")]) with pytest.raises(httpx.HTTPStatusError): _get_with_backoff(client, "https://www.pravo.gov66.ru/1/") # Только 1 pacing-sleep перед единственной попыткой, никаких backoff-ретраев. assert len(sleeps) == 1