"""Тесты permits_nearby (#105 Phase 3 — точный радиус-запрос РНС/РВЭ по geom, ГИСОГД-66). Живую БД не трогаем — MagicMock-сессия (зеркало test_supply_layers / test_market_metrics): `db.execute().mappings().all()` отдаёт заранее заданные строки. Проверяем: • честный ноль на пустом радиусе (не ошибка, не None), • разделение rs_count/rv_count по doc_group, • округление distance_m + nearest_distance_m, • ISO-сериализацию date_doc, • ЧЕСТНЫЕ счётчики по полной выборке БЕЗ тихого капа (SQL без LIMIT), items капается отдельно до 30 → items_truncated, • отсутствие :name::type в SQL, порядок ORDER BY distance_m ASC, • non-fatal семантику (сбой db.execute не глотается молча — пробрасывается вызывающему). """ from __future__ import annotations import datetime as dt import re from typing import Any from unittest.mock import MagicMock import pytest from app.services.site_finder.permits_nearby import ( _PERMITS_NEARBY_SQL, get_permits_nearby, ) _WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))" def _mock_db(rows: list[dict[str, Any]]) -> MagicMock: """Сессия с одним execute → .mappings().all() == rows.""" db = MagicMock() result = MagicMock() result.mappings.return_value.all.return_value = rows db.execute.return_value = result return db def _row( doc_group: str, distance_m: float, *, doc_name: str = "Разрешение", doc_num: str = "66-01", date_doc: dt.date | None = None, approved_organization: str = "Администрация ЕКБ", date_reg: dt.date | None = None, ) -> dict[str, Any]: return { "doc_group": doc_group, "doc_name": doc_name, "doc_num": doc_num, "date_doc": date_doc, "date_reg": date_reg, "approved_organization": approved_organization, "distance_m": distance_m, } def test_empty_radius_is_honest_zero_not_none() -> None: """0 строк в радиусе → честный ноль (total_count=0, items=[]), НЕ None, НЕ ошибка.""" out = get_permits_nearby(_mock_db([]), _WKT) assert out is not None assert out["total_count"] == 0 assert out["rs_count"] == 0 assert out["rv_count"] == 0 assert out["nearest_distance_m"] is None assert out["items"] == [] assert out["items_truncated"] is False assert out["radius_m"] == 500 assert out["source"] == "gisogd66" def test_rs_rv_counts_split_by_doc_group() -> None: """rs_count считает 'RS', rv_count — 'RV'; прочие группы не попадают ни в один.""" rows = [ _row("RS", 100.0), _row("RS", 150.0), _row("RV", 200.0), _row("OTHER", 250.0), ] out = get_permits_nearby(_mock_db(rows), _WKT) assert out["total_count"] == 4 assert out["rs_count"] == 2 assert out["rv_count"] == 1 assert len(out["items"]) == 4 assert out["items_truncated"] is False def test_distance_rounding_and_nearest() -> None: """distance_m округляется до 1 знака; nearest_distance_m = ближайший (rows отсортированы).""" rows = [_row("RS", 123.456), _row("RV", 456.789)] out = get_permits_nearby(_mock_db(rows), _WKT) assert out["items"][0]["distance_m"] == 123.5 assert out["items"][1]["distance_m"] == 456.8 assert out["nearest_distance_m"] == 123.5 def test_date_doc_serialized_to_iso() -> None: """date_doc → ISO-строка; None → None (не падает).""" rows = [ _row("RS", 100.0, date_doc=dt.date(2025, 3, 14)), _row("RV", 200.0, date_doc=None), ] out = get_permits_nearby(_mock_db(rows), _WKT) assert out["items"][0]["date_doc"] == "2025-03-14" assert out["items"][1]["date_doc"] is None def test_item_shape_fields() -> None: """Каждый item несёт ровно контрактные ключи (не протекает date_reg и пр.).""" rows = [_row("RS", 42.0, doc_name="РНС №5", approved_organization="Мингос")] out = get_permits_nearby(_mock_db(rows), _WKT) item = out["items"][0] assert set(item.keys()) == { "doc_group", "doc_name", "doc_num", "date_doc", "approved_organization", "distance_m", } assert item["doc_name"] == "РНС №5" assert item["approved_organization"] == "Мингос" def test_radius_m_passed_through_to_params_and_result() -> None: """Кастомный radius_m прокидывается в bind-params И в выходной agg.""" db = _mock_db([]) out = get_permits_nearby(db, _WKT, radius_m=1000) assert out["radius_m"] == 1000 _args, _kwargs = db.execute.call_args params = _args[1] assert params["radius_m"] == 1000 assert params["wkt"] == _WKT def test_sql_no_limit_counts_over_full_selection() -> None: """SQL БЕЗ LIMIT (честные счётчики по всей выборке) + ORDER BY / ST_DWithin. Кап 30 делается в Python на items, НЕ в SQL — иначе total_count/rs_count/rv_count занижались бы (тихий кап, запрещён проектной конвенцией). """ sql = str(_PERMITS_NEARBY_SQL).lower() assert "gisogd_permits" in sql assert "st_dwithin" in sql assert "order by distance_m asc" in sql assert "limit" not in sql, "LIMIT в SQL капнул бы счётчики — кап только на items в Python" # items_limit больше не bind-параметр (капаем rows[:30] в Python). db = _mock_db([]) get_permits_nearby(db, _WKT) _args, _kwargs = db.execute.call_args assert "items_limit" not in _args[1] def test_items_capped_but_counts_honest_over_full_selection() -> None: """35 строк в выборке → items len=30 (кап), но total_count=35 честный + truncated=True. Ядро фикса «без тихих капов»: список отдаётся усечённым (progressive-disclosure на фронте по items_truncated), а счётчики отражают РЕАЛЬНОЕ число разрешений в радиусе. """ # 20×RS ближайшие, затем 15×RV дальше — итого 35, отсортированы по distance ASC. rows = [_row("RS", float(d)) for d in range(10, 30)] # 20 строк, 10..29 м rows += [_row("RV", float(d)) for d in range(30, 45)] # 15 строк, 30..44 м assert len(rows) == 35 out = get_permits_nearby(_mock_db(rows), _WKT) assert out["total_count"] == 35 assert out["rs_count"] == 20 assert out["rv_count"] == 15 assert len(out["items"]) == 30 assert out["items_truncated"] is True # nearest — по ближайшей строке (сохраняется даже при капе списка). assert out["nearest_distance_m"] == 10.0 # items — именно первые 30 ближайших (последние 5 дальних RV не попали). assert all(it["distance_m"] <= 39.0 for it in out["items"]) def test_sql_no_double_colon_cast() -> None: """psycopg v3 / backend.md: никаких :name::type (::geography приклеен к ')', OK).""" sql = str(_PERMITS_NEARBY_SQL) assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}" def test_db_error_propagates_not_swallowed() -> None: """Сбой db.execute пробрасывается (non-fatal обёртка живёт в parcels.py, не тут).""" db = MagicMock() db.execute.side_effect = RuntimeError("db down") with pytest.raises(RuntimeError, match="db down"): get_permits_nearby(db, _WKT)