646 lines
29 KiB
Python
646 lines
29 KiB
Python
"""Тесты клиента АИС ЕГРКН (#1066, #1159) — без живой сети.
|
||
|
||
Покрывает:
|
||
- coord swap: фейк-фича coordinates=[lat, lon] → lon/lat для PostGIS (критично).
|
||
- TLS-fallback: SSLError с verify=True → retry с verify=False.
|
||
- Некорректный JSON / HTTP-ошибка → пустой список, не raise.
|
||
- _build_form_data: формат jQuery serializeArray.
|
||
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
|
||
- fetch_okn_detail: HTML карточки → {name, address, …}; graceful при ошибках.
|
||
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
|
||
- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
|
||
экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
|
||
исчерпываются на устойчивой ошибке без raise.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from app.services.scrapers.okn_egrkn_client import (
|
||
_HEADERS,
|
||
_build_form_data,
|
||
_parse_detail_html,
|
||
fetch_okn_detail,
|
||
fetch_okn_points,
|
||
)
|
||
|
||
# ── Вспомогательные функции ───────────────────────────────────────────────────
|
||
|
||
|
||
def _make_geojson_response(features: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""Сформировать GeoJSON FeatureCollection."""
|
||
return {"type": "FeatureCollection", "features": features}
|
||
|
||
|
||
def _make_feature(
|
||
feature_id: str,
|
||
lat: float,
|
||
lon: float,
|
||
props: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Минимальная фича ЕГРКН с координатами в порядке [lat, lon] (Яндекс)."""
|
||
return {
|
||
"id": feature_id,
|
||
"geometry": {"type": "Point", "coordinates": [lat, lon]},
|
||
"properties": props or {},
|
||
}
|
||
|
||
|
||
# ── КРИТИЧНО: coord swap ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_coord_swap_ekb_point_inside_bbox() -> None:
|
||
"""ЕГРКН возвращает [lat, lon]; клиент должен вернуть фичу как есть.
|
||
|
||
Тест проверяет, что _extract_coords в воркере корректно инвертирует:
|
||
coordinates=[56.83, 60.61] → lat=56.83 lon=60.61 → MakePoint(lon=60.61, lat=56.83).
|
||
Прямая проверка: ЕКБ bbox 55–58°N, 58–63°E — lon=60.61 и lat=56.83 входят.
|
||
"""
|
||
# Фича: coordinates[0]=lat=56.83, coordinates[1]=lon=60.61 (центр ЕКБ)
|
||
feature = _make_feature("test-ekb-001", lat=56.83, lon=60.61)
|
||
coords = feature["geometry"]["coordinates"]
|
||
|
||
# coordinates[0] = lat (56.83°N — севернее экватора, в ЕКБ)
|
||
# coordinates[1] = lon (60.61°E — в Свердловской обл.)
|
||
lat = float(coords[0])
|
||
lon = float(coords[1])
|
||
|
||
# Проверяем что при правильной интерпретации точка попадает в ЕКБ-bbox
|
||
assert 55.0 <= lat <= 58.5, f"lat={lat} вне ЕКБ-bbox 55–58.5°N"
|
||
assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 58–63°E"
|
||
|
||
# Проверяем что lat/lon НЕ перепутаны местами:
|
||
# если бы мы взяли coordinates[0] как lon, то lon=56.83 — это не в Свердловской обл.
|
||
# а как lat взяли бы coordinates[1]=60.61 — 60.61°N это Финляндия, не ЕКБ
|
||
wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat
|
||
wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon
|
||
# Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан
|
||
assert not (
|
||
55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0
|
||
), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
|
||
|
||
|
||
def test_coord_swap_inverted_would_fail_bbox_check() -> None:
|
||
"""Если бы lat/lon были перепутаны — санитарная проверка воркера отклонила бы точку.
|
||
|
||
В okn_objects_sync._extract_coords валидный bbox: 55–58.5°N, 58–63°E.
|
||
При перестановке coordinates[0]=56.83 → трактуется как lon=56.83°E (вне 58–63°E)
|
||
и coordinates[1]=60.61 → трактуется как lat=60.61°N (вне 55–58.5°N).
|
||
"""
|
||
# Имитируем НЕВЕРНУЮ интерпретацию: lon берём из coords[0], lat из coords[1]
|
||
feature = _make_feature("test-swap-check", lat=56.83, lon=60.61)
|
||
coords = feature["geometry"]["coordinates"]
|
||
|
||
wrong_lon = float(coords[0]) # 56.83 — интерпретируем как lon (неверно)
|
||
wrong_lat = float(coords[1]) # 60.61 — интерпретируем как lat (неверно)
|
||
|
||
inside_ekb = (55.0 <= wrong_lat <= 58.5) and (58.0 <= wrong_lon <= 63.0)
|
||
assert not inside_ekb, (
|
||
"Перепутанные координаты ошибочно прошли бы bbox-фильтр — "
|
||
"фикс coord swap в _extract_coords сломан"
|
||
)
|
||
|
||
|
||
# ── fetch_okn_points: мок httpx ───────────────────────────────────────────────
|
||
|
||
|
||
def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
|
||
"""Создать мок httpx.Response."""
|
||
resp = MagicMock()
|
||
resp.status_code = status
|
||
resp.json.return_value = payload
|
||
resp.raise_for_status = MagicMock()
|
||
# headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
|
||
# в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
|
||
resp.headers = {}
|
||
if status >= 400:
|
||
import httpx
|
||
|
||
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||
"error", request=MagicMock(), response=resp
|
||
)
|
||
return resp
|
||
|
||
|
||
def _mock_html_response(html: str, status: int = 200) -> MagicMock:
|
||
"""Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
|
||
resp = MagicMock()
|
||
resp.status_code = status
|
||
resp.text = html
|
||
resp.raise_for_status = MagicMock()
|
||
# См. _mock_response — headers должен быть настоящим dict, не MagicMock.
|
||
resp.headers = {}
|
||
if status >= 400:
|
||
import httpx
|
||
|
||
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||
"error", request=MagicMock(), response=resp
|
||
)
|
||
return resp
|
||
|
||
|
||
def test_fetch_okn_points_returns_features() -> None:
|
||
"""fetch_okn_points возвращает список фич из FeatureCollection."""
|
||
features = [
|
||
_make_feature("001", 56.83, 60.61),
|
||
_make_feature("002", 56.84, 60.62),
|
||
]
|
||
payload = _make_geojson_response(features)
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.post.return_value = _mock_response(payload)
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert len(result) == 2
|
||
assert result[0]["id"] == "001"
|
||
assert result[1]["id"] == "002"
|
||
|
||
|
||
def test_fetch_okn_points_empty_features() -> None:
|
||
"""Пустой FeatureCollection → пустой список."""
|
||
payload = _make_geojson_response([])
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.post.return_value = _mock_response(payload)
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||
result = fetch_okn_points("Екатеринбург", 2)
|
||
|
||
assert result == []
|
||
|
||
|
||
def test_fetch_okn_points_http_error_returns_empty() -> None:
|
||
"""HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.post.return_value = _mock_response({}, status=500)
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
result = fetch_okn_points("Свердловская", 1)
|
||
|
||
assert result == []
|
||
# 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
|
||
assert mock_sleep.call_count >= 1
|
||
|
||
|
||
def test_fetch_okn_points_invalid_json_returns_empty() -> None:
|
||
"""Невалидный JSON в ответе → пустой список."""
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
resp = MagicMock()
|
||
resp.raise_for_status = MagicMock()
|
||
resp.json.side_effect = ValueError("not json")
|
||
mock_client.post.return_value = resp
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||
result = fetch_okn_points("Екатеринбург", 4)
|
||
|
||
assert result == []
|
||
|
||
|
||
def test_fetch_okn_points_ssl_error_retries_with_verify_false() -> None:
|
||
"""SSL ConnectError при verify=True → автоматический retry с verify=False.
|
||
|
||
httpx >= 0.28 не имеет отдельного SSLError — SSL-ошибки приходят как ConnectError
|
||
с SSL-ключевым словом в сообщении. Клиент определяет это через _is_ssl_error().
|
||
"""
|
||
import httpx
|
||
|
||
features = [_make_feature("ssl-001", 56.83, 60.61)]
|
||
payload = _make_geojson_response(features)
|
||
|
||
# Первый клиент (verify=True) бросает ConnectError с SSL-сообщением
|
||
# Второй клиент (verify=False) возвращает корректный ответ
|
||
clients_created: list[dict] = []
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
verify = kwargs.get("verify", True)
|
||
clients_created.append({"verify": verify})
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
if verify:
|
||
# ConnectError с SSL в сообщении — типичная ошибка verify=True для RU CA
|
||
mock_client.post.side_effect = httpx.ConnectError("ssl cert verify failed")
|
||
else:
|
||
mock_client.post.return_value = _mock_response(payload)
|
||
return mock_client
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert len(result) == 1
|
||
assert result[0]["id"] == "ssl-001"
|
||
# Оба клиента были созданы: первый verify=True, второй verify=False
|
||
assert len(clients_created) == 2
|
||
assert clients_created[0]["verify"] is True
|
||
assert clients_created[1]["verify"] is False
|
||
|
||
|
||
# ── _build_form_data ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_build_form_data_structure() -> None:
|
||
"""form_data содержит правильные пары name/value для jQuery serializeArray."""
|
||
result = _build_form_data("Екатеринбург", 1)
|
||
|
||
assert ("data[0][name]", "CultureObjects[cob_address]") in result
|
||
assert ("data[0][value]", "Екатеринбург") in result
|
||
assert ("data[1][name]", "CultureObjects[cob_category_type]") in result
|
||
assert ("data[1][value]", "1") in result
|
||
|
||
|
||
# -- User-Agent header (bypass 403) ------------------------------------------
|
||
|
||
|
||
def test_request_sends_browser_user_agent() -> None:
|
||
"""httpx.Client is created with browser User-Agent from _HEADERS (bypass 403).
|
||
|
||
Without UA, okn-mk.mkrf.ru returns 403.
|
||
With Chrome/125 UA: 200 response, cob_address=Ekaterinburg = 272 features.
|
||
Checks:
|
||
1. _HEADERS constant contains User-Agent with Mozilla string and Content-Type.
|
||
2. httpx.Client is called with headers=_HEADERS when fetch_okn_points runs.
|
||
"""
|
||
assert "User-Agent" in _HEADERS
|
||
assert "Mozilla" in _HEADERS["User-Agent"]
|
||
assert "X-Requested-With" in _HEADERS
|
||
assert "Referer" in _HEADERS
|
||
assert "Origin" in _HEADERS
|
||
assert "Content-Type" in _HEADERS
|
||
assert "application/x-www-form-urlencoded" in _HEADERS["Content-Type"]
|
||
|
||
features = [_make_feature("ua-001", 56.83, 60.61)]
|
||
payload = _make_geojson_response(features)
|
||
captured_kwargs: list[dict] = []
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
captured_kwargs.append(dict(kwargs))
|
||
mc = MagicMock()
|
||
mc.__enter__ = MagicMock(return_value=mc)
|
||
mc.__exit__ = MagicMock(return_value=False)
|
||
mc.post.return_value = _mock_response(payload)
|
||
return mc
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
|
||
fetch_okn_points("Ekaterinburg", 1)
|
||
|
||
assert len(captured_kwargs) == 1
|
||
sent_headers = captured_kwargs[0].get("headers", {})
|
||
assert "User-Agent" in sent_headers
|
||
assert "Mozilla" in sent_headers["User-Agent"]
|
||
|
||
|
||
def test_post_uses_content_urlencode_not_data() -> None:
|
||
"""client.post вызывается с content=urlencoded-строкой (не data=list[tuple]).
|
||
|
||
Баг #1066: data=list[tuple] + ручной Content-Type → httpx бросает
|
||
«expected bytes, tuple found». Фикс: urllib.parse.urlencode(form_data) → content=.
|
||
Проверяем:
|
||
- post вызван с параметром content=, а НЕ data=
|
||
- content — строка с правильными urlencoded-ключами (повторяющиеся data[i][name])
|
||
- UA-заголовок присутствует в headers переданных в Client
|
||
"""
|
||
import urllib.parse
|
||
|
||
features = [_make_feature("enc-001", 56.83, 60.61)]
|
||
payload = _make_geojson_response(features)
|
||
captured_post_kwargs: list[dict] = []
|
||
captured_client_kwargs: list[dict] = []
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
captured_client_kwargs.append(dict(kwargs))
|
||
mc = MagicMock()
|
||
mc.__enter__ = MagicMock(return_value=mc)
|
||
mc.__exit__ = MagicMock(return_value=False)
|
||
|
||
def _post(url: str, **post_kwargs: Any) -> MagicMock:
|
||
captured_post_kwargs.append(post_kwargs)
|
||
return _mock_response(payload)
|
||
|
||
mc.post.side_effect = _post
|
||
return mc
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert len(result) == 1
|
||
|
||
assert len(captured_post_kwargs) == 1
|
||
post_kw = captured_post_kwargs[0]
|
||
|
||
# content= должен быть, data= — нет
|
||
assert "content" in post_kw, "post() должен использовать content=, а не data="
|
||
assert "data" not in post_kw, "post() не должен передавать data= (баг с tuple-encoding)"
|
||
|
||
# content — корректная urlencoded-строка
|
||
body = post_kw["content"]
|
||
assert isinstance(body, str), f"content должен быть str, получили {type(body)}"
|
||
parsed = urllib.parse.parse_qs(body, keep_blank_values=True)
|
||
assert "data[0][name]" in parsed
|
||
assert "data[0][value]" in parsed
|
||
assert "data[1][name]" in parsed
|
||
assert "data[1][value]" in parsed
|
||
assert parsed["data[0][value]"] == ["Екатеринбург"]
|
||
assert parsed["data[1][value]"] == ["1"]
|
||
|
||
# UA в заголовках клиента
|
||
client_headers = captured_client_kwargs[0].get("headers", {})
|
||
assert "User-Agent" in client_headers
|
||
assert "Mozilla" in client_headers["User-Agent"]
|
||
|
||
|
||
def test_build_form_data_category_type_as_string() -> None:
|
||
"""category_type конвертируется в строку (httpx data= ожидает str)."""
|
||
result = _build_form_data("Свердловская", 4)
|
||
values = dict(result)
|
||
assert values["data[1][value]"] == "4"
|
||
|
||
|
||
# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────
|
||
|
||
|
||
_DETAIL_HTML_DL = """
|
||
<html><body>
|
||
<dl class="okn-card">
|
||
<dt>Наименование</dt><dd>Дом Севастьянова</dd>
|
||
<dt>Адрес</dt><dd>г. Екатеринбург, пр. Ленина, 35</dd>
|
||
<dt>Категория историко-культурного значения</dt><dd>федерального</dd>
|
||
<dt>Вид объекта</dt><dd>памятник архитектуры</dd>
|
||
<dt>Датировка</dt><dd>1866</dd>
|
||
<dt>Документ о постановке на гос. охрану</dt><dd>Постановление Правительства РФ № 527</dd>
|
||
<dt>Регистрационный номер</dt><dd>661510234190006</dd>
|
||
</dl>
|
||
</body></html>
|
||
"""
|
||
|
||
|
||
_DETAIL_HTML_TABLE = """
|
||
<html><body>
|
||
<table class="okn">
|
||
<tr><td>Наименование</td><td>Гостиница «Большой Урал»</td></tr>
|
||
<tr><td>Адрес</td><td>г. Екатеринбург, ул. Красноармейская, 1</td></tr>
|
||
<tr><td>Категория историко-культурного значения</td><td>регионального</td></tr>
|
||
</table>
|
||
</body></html>
|
||
"""
|
||
|
||
|
||
def test_parse_detail_html_dl_extracts_all_fields() -> None:
|
||
"""<dl><dt>/<dd> структура → все поля извлечены."""
|
||
result = _parse_detail_html(_DETAIL_HTML_DL)
|
||
assert result["name"] == "Дом Севастьянова"
|
||
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
|
||
assert result["protection_category"] == "федерального"
|
||
assert result["object_kind"] == "памятник архитектуры"
|
||
assert result["dating"] == "1866"
|
||
assert result["protection_doc"] == "Постановление Правительства РФ № 527"
|
||
assert result["regnumber"] == "661510234190006"
|
||
|
||
|
||
def test_parse_detail_html_table_extracts_name_address() -> None:
|
||
"""<table><tr><td>label</td><td>value</td></tr> → парсится."""
|
||
result = _parse_detail_html(_DETAIL_HTML_TABLE)
|
||
assert result["name"] == "Гостиница «Большой Урал»"
|
||
assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
|
||
assert result["protection_category"] == "регионального"
|
||
|
||
|
||
def test_parse_detail_html_empty_returns_empty_dict() -> None:
|
||
"""Пустой/None HTML → {}."""
|
||
assert _parse_detail_html("") == {}
|
||
assert _parse_detail_html(" ") == {}
|
||
|
||
|
||
def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
|
||
"""HTML без полей-маркеров → пустой dict."""
|
||
html = "<html><body><p>404 not found</p></body></html>"
|
||
assert _parse_detail_html(html) == {}
|
||
|
||
|
||
def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
|
||
"""GET карточки + парсинг → {name, address, …}."""
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
|
||
result = fetch_okn_detail("661510234190006")
|
||
|
||
assert result["name"] == "Дом Севастьянова"
|
||
assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
|
||
# GET был вызван с params={"id": "..."}.
|
||
mock_client.get.assert_called_once()
|
||
call_kwargs = mock_client.get.call_args.kwargs
|
||
assert call_kwargs.get("params") == {"id": "661510234190006"}
|
||
|
||
|
||
def test_fetch_okn_detail_http_error_returns_empty() -> None:
|
||
"""HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.get.return_value = _mock_html_response("", status=500)
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep"),
|
||
):
|
||
result = fetch_okn_detail("001")
|
||
|
||
assert result == {}
|
||
|
||
|
||
def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
|
||
"""Пустой source_id → пустой dict без HTTP-вызова."""
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
|
||
result = fetch_okn_detail("")
|
||
assert result == {}
|
||
mock_cls.assert_not_called()
|
||
|
||
|
||
def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
|
||
"""SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
|
||
import httpx
|
||
|
||
clients: list[dict] = []
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
verify = kwargs.get("verify", True)
|
||
clients.append({"verify": verify})
|
||
mc = MagicMock()
|
||
mc.__enter__ = MagicMock(return_value=mc)
|
||
mc.__exit__ = MagicMock(return_value=False)
|
||
if verify:
|
||
mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
|
||
else:
|
||
mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
|
||
return mc
|
||
|
||
with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
|
||
result = fetch_okn_detail("test-id")
|
||
|
||
assert result["name"] == "Дом Севастьянова"
|
||
assert len(clients) == 2
|
||
assert clients[0]["verify"] is True
|
||
assert clients[1]["verify"] is False
|
||
|
||
|
||
# ── WAF-hygiene: rate-limit + 429/5xx backoff (#2445 item D3) ────────────────
|
||
|
||
|
||
def test_fetch_okn_points_sleeps_between_requests_for_rate_limit() -> None:
|
||
"""Второй вызов fetch_okn_points почти сразу после первого → time.sleep вызван
|
||
(rate-limit держит паузу между последовательными запросами к WAF-хосту).
|
||
"""
|
||
payload = _make_geojson_response([_make_feature("rl-001", 56.83, 60.61)])
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.post.return_value = _mock_response(payload)
|
||
|
||
import app.services.scrapers.okn_egrkn_client as client_mod
|
||
|
||
# Форсируем "последний запрос был только что" — следующий вызов обязан поспать.
|
||
client_mod._last_request_ts = client_mod.time.time()
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
fetch_okn_points("Екатеринбург", 1)
|
||
|
||
mock_sleep.assert_called_once()
|
||
(slept_s,) = mock_sleep.call_args.args
|
||
assert 0 < slept_s <= client_mod._RATE_LIMIT_MS / 1000.0
|
||
|
||
|
||
def test_fetch_okn_detail_shares_rate_limit_with_searchmap() -> None:
|
||
"""Rate-limit общий между searchMap и detail — один WAF-хост okn-mk.mkrf.ru."""
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
|
||
|
||
import app.services.scrapers.okn_egrkn_client as client_mod
|
||
|
||
client_mod._last_request_ts = client_mod.time.time()
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
fetch_okn_detail("test-id")
|
||
|
||
mock_sleep.assert_called_once()
|
||
|
||
|
||
def test_do_request_backs_off_on_429_then_succeeds() -> None:
|
||
"""429 → экспоненциальный backoff (time.sleep) → retry → успех на второй попытке."""
|
||
features = [_make_feature("429-001", 56.83, 60.61)]
|
||
payload = _make_geojson_response(features)
|
||
|
||
responses = [_mock_response({}, status=429), _mock_response(payload)]
|
||
# Первая ответка raise_for_status не должна вызываться — код читает status_code
|
||
# напрямую и уходит в backoff-branch до raise_for_status().
|
||
call_count = {"n": 0}
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
mc = MagicMock()
|
||
mc.__enter__ = MagicMock(return_value=mc)
|
||
mc.__exit__ = MagicMock(return_value=False)
|
||
|
||
def _post(url: str, **post_kwargs: Any) -> MagicMock:
|
||
resp = responses[call_count["n"]]
|
||
call_count["n"] += 1
|
||
return resp
|
||
|
||
mc.post.side_effect = _post
|
||
return mc
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert len(result) == 1
|
||
assert result[0]["id"] == "429-001"
|
||
assert call_count["n"] == 2
|
||
# Backoff перед повтором вызван хотя бы раз (плюс возможные rate-limit sleeps).
|
||
assert mock_sleep.call_count >= 1
|
||
|
||
|
||
def test_do_request_gives_up_after_max_retries_on_persistent_429() -> None:
|
||
"""Устойчивый 429 → ретраи исчерпаны (_MAX_RETRIES), возврат [] без raise."""
|
||
import app.services.scrapers.okn_egrkn_client as client_mod
|
||
|
||
mock_client = MagicMock()
|
||
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
||
mock_client.__exit__ = MagicMock(return_value=False)
|
||
mock_client.post.return_value = _mock_response({}, status=429)
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert result == []
|
||
# attempt=0..._MAX_RETRIES-1 включительно ретраятся → _MAX_RETRIES backoff-sleeps
|
||
# (плюс rate-limit sleeps на каждую из _MAX_RETRIES+1 попыток).
|
||
assert mock_sleep.call_count >= client_mod._MAX_RETRIES
|
||
|
||
|
||
def test_do_request_retry_after_header_respected_and_capped() -> None:
|
||
"""Retry-After заголовок уважается для расчёта backoff (капается _MAX_BACKOFF_S)."""
|
||
import app.services.scrapers.okn_egrkn_client as client_mod
|
||
|
||
huge_retry_after = str(int(client_mod._MAX_BACKOFF_S) + 500)
|
||
resp_429 = _mock_response({}, status=429)
|
||
resp_429.headers = {"Retry-After": huge_retry_after}
|
||
resp_ok = _mock_response(_make_geojson_response([_make_feature("ra-001", 56.83, 60.61)]))
|
||
resp_ok.headers = {}
|
||
|
||
responses = [resp_429, resp_ok]
|
||
call_count = {"n": 0}
|
||
|
||
def _make_client(**kwargs: Any) -> MagicMock:
|
||
mc = MagicMock()
|
||
mc.__enter__ = MagicMock(return_value=mc)
|
||
mc.__exit__ = MagicMock(return_value=False)
|
||
|
||
def _post(url: str, **post_kwargs: Any) -> MagicMock:
|
||
resp = responses[call_count["n"]]
|
||
call_count["n"] += 1
|
||
return resp
|
||
|
||
mc.post.side_effect = _post
|
||
return mc
|
||
|
||
with (
|
||
patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
|
||
patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
|
||
):
|
||
result = fetch_okn_points("Екатеринбург", 1)
|
||
|
||
assert len(result) == 1
|
||
# Backoff-sleep вызывался хотя бы раз с длительностью <= _MAX_BACKOFF_S
|
||
# (Retry-After капается потолком, а не используется как есть).
|
||
backoff_calls = [c.args[0] for c in mock_sleep.call_args_list]
|
||
assert any(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
|
||
assert all(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
|