All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m17s
CI / backend-tests (pull_request) Successful in 6m15s
276 lines
12 KiB
Python
276 lines
12 KiB
Python
"""Тесты клиента АИС ЕГРКН (#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 (
|
||
_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 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()
|
||
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.
|
||
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
|
||
|
||
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_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"
|