"""Тесты клиента АИС ЕГРКН (#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:
/
/
и / структура парсится; маркеры label. """ 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() 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() 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" # ── fetch_okn_detail / _parse_detail_html (#1159) ──────────────────────────── _DETAIL_HTML_DL = """
Наименование
Дом Севастьянова
Адрес
г. Екатеринбург, пр. Ленина, 35
Категория историко-культурного значения
федерального
Вид объекта
памятник архитектуры
Датировка
1866
Документ о постановке на гос. охрану
Постановление Правительства РФ № 527
Регистрационный номер
661510234190006
""" _DETAIL_HTML_TABLE = """
НаименованиеГостиница «Большой Урал»
Адресг. Екатеринбург, ул. Красноармейская, 1
Категория историко-культурного значениярегионального
""" def test_parse_detail_html_dl_extracts_all_fields() -> None: """
/
структура → все поля извлечены.""" 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: """ → парсится.""" 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 = "

404 not found

" 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.""" 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): 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
labelvalue