gendesign/backend/tests/scrapers/test_pravo_gov66_client.py
bot-backend d1fa450f48
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
fix(backend): reservation_ingest пейсинг + короткоживущая DB-сессия (#2445-D4) (#2458)
2026-07-05 20:09:00 +00:00

269 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты 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 = """
<!DOCTYPE html>
<html>
<head><title>Поиск</title></head>
<body>
<div class="search-results">
<ul>
<li class="result-item">
<a href="/35496/">
Об изъятии земельных участков для государственных нужд
Свердловской области в целях реконструкции ул. Татищева
в г. Екатеринбурге
</a>
</li>
<li class="result-item">
<a href="/34812/">
О резервировании земельных участков для нужд Екатеринбург
</a>
</li>
<li class="result-item">
<!-- Ссылка на категорию, не документ — должна игнорироваться -->
<a href="/category/law/">Категория</a>
</li>
<li class="result-item">
<!-- Дубль doc_id 35496 — должен дедуплицироваться -->
<a href="/35496/">Дубль</a>
</li>
<li class="result-item">
<!-- Нечисловой id — не должен попасть -->
<a href="/some-slug/">Текстовый slug</a>
</li>
</ul>
</div>
</body>
</html>
"""
# ── Фикстура HTML страницы документа с обычным PDF ────────────────────────────
_DOC_HTML_WITH_PDF = """
<!DOCTYPE html>
<html>
<body>
<h1>Постановление № 509-ПП</h1>
<p>
Скачать документ:
<a href="/media/pravo/509-ПП_ab1234cd.pdf">Скачать PDF</a>
</p>
<p>
Подписанная версия:
<a href="/media/pravo/ecp/509-ПП.pdf">Скачать подписанный PDF</a>
</p>
</body>
</html>
"""
# ── Фикстура HTML страницы документа только с ecp PDF ─────────────────────────
_DOC_HTML_ECP_ONLY = """
<!DOCTYPE html>
<html>
<body>
<h1>Постановление № 312-ПП</h1>
<p>
<a href="/media/pravo/ecp/312-ПП.pdf">Подписанный PDF</a>
</p>
</body>
</html>
"""
# ── Фикстура HTML страницы документа без PDF ──────────────────────────────────
_DOC_HTML_NO_PDF = """
<!DOCTYPE html>
<html>
<body>
<h1>Постановление без PDF</h1>
<p>Текст постановления размещён ниже.</p>
</body>
</html>
"""
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 = "<html><body><a href='/category/'>Cat</a></body></html>"
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