gendesign/backend/tests/services/scrapers/test_okn_egrkn_client.py
bot-backend a6e100b52a
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m14s
fix(sf): корректное urlencode form-data в okn_egrkn_client (#1147)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:13:34 +00:00

338 lines
15 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.

"""Тесты клиента АИС ЕГРКН (#1066) — без живой сети.
Покрывает:
- 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=).
"""
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,
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 5558°N, 5863°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 5558.5°N"
assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 5863°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 формально в диапазоне 5558.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: 5558.5°N, 5863°E.
При перестановке coordinates[0]=56.83 → трактуется как lon=56.83°E (вне 5863°E)
и coordinates[1]=60.61 → трактуется как lat=60.61°N (вне 5558.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()
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)."""
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):
result = fetch_okn_points("Свердловская", 1)
assert result == []
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"