"""Тесты клиента АИС ЕГРКН (#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 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 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"