gendesign/backend/tests/services/scrapers/test_okn_egrkn_client.py
lekss361 50fcba1ca0
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m23s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m15s
feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1141)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 12:51:18 +00:00

233 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.

"""Тесты клиента АИС ЕГРКН (#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.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from app.services.scrapers.okn_egrkn_client import _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
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"