/ структура парсится; маркеры 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:
"""
| label | value | → парсится."""
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
|