"""Тесты GET /{cad_num}/neighbor-buildings (Forgejo #2180). Лёгкий синхронный endpoint соседних footprint'ов для 3D-сцены §7 «Размещение застройки»: GeoJSON FeatureCollection упрощённых полигонов cad_buildings вокруг участка. Механика моков: get_db переопределяем через app.dependency_overrides (FastAPI Depends держит оригинальную ссылку — unittest.mock.patch неэффективен). Хендлер делает один основной `db.execute(...).mappings().all()` (соседи), и ТОЛЬКО при пустом результате — второй `db.execute(...).first()` (EXISTS-пробник участка, 404 vs пустой 200). Мок покрывает обе формы. Очистку dependency_overrides делает autouse-фикстура в tests/conftest.py. RBAC-гейт в test-mode (settings.testing) байпасится — auth-заголовок не нужен. """ from __future__ import annotations import json from typing import Any from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.core.db import get_db from app.main import app _VALID_CAD = "66:41:0204016:10" # Валидный GeoJSON-полигон footprint'а — как отдаёт ST_AsGeoJSON (строка). _POLYGON_GEOJSON = json.dumps( { "type": "Polygon", "coordinates": [ [ [60.640, 56.830], [60.641, 56.830], [60.641, 56.831], [60.640, 56.831], [60.640, 56.830], ] ], } ) def _make_building_row( cad_num: str = "66:41:0204016:100", floors: int | None = 9, building_name: str | None = "Жилой дом", year_built: int | None = 1998, distance_m: float = 42.0, geojson: str | None = _POLYGON_GEOJSON, ) -> dict[str, Any]: """Одна строка результата _NEIGHBOR_BUILDINGS_SQL (mappings()).""" return { "cad_num": cad_num, "floors": floors, "building_name": building_name, "year_built": year_built, "geojson": geojson, "distance_m": distance_m, } def _build_mock_db( rows: list[dict[str, Any]], *, parcel_exists: bool = True, ) -> MagicMock: """Mock Session. Основной запрос соседей → .mappings().all() = rows. Fallback EXISTS-пробник (вызывается лишь при пустом rows) → .first() = (1,) если parcel_exists иначе None. """ mock_db = MagicMock() mappings_mock = MagicMock() mappings_mock.all.return_value = rows execute_result = MagicMock() execute_result.mappings.return_value = mappings_mock # .first() дёргается только на EXISTS-пробнике (тот же execute_result — ок, # т.к. пробник запускается только когда rows пуст). execute_result.first.return_value = (1,) if parcel_exists else None mock_db.execute.return_value = execute_result return mock_db def _override_db(db: MagicMock): def _get_db_override(): yield db return _get_db_override @pytest.fixture() def client() -> TestClient: return TestClient(app) # ── happy path: фичи + floors → height_m ────────────────────────────────── def test_neighbor_buildings_happy_path(client: TestClient) -> None: rows = [ _make_building_row("66:41:0204016:100", floors=9, distance_m=30.0), _make_building_row("66:41:0204016:101", floors=16, distance_m=85.0), _make_building_row( "66:41:0204016:102", floors=None, # floors неизвестны → height_m null building_name="", # пустое имя → null year_built=None, distance_m=120.0, ), ] app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") assert r.status_code == 200, r.text body = r.json() assert body["type"] == "FeatureCollection" assert body["count"] == 3 assert body["radius_m"] == 300 assert body["truncated"] is False assert len(body["features"]) == 3 f0 = body["features"][0] assert f0["type"] == "Feature" assert f0["geometry"]["type"] == "Polygon" # распарсен из строки, не escaped props0 = f0["properties"] assert props0["cad_num"] == "66:41:0204016:100" assert props0["floors"] == 9 assert props0["height_m"] == pytest.approx(27.0) # 9 × 3.0 assert props0["building_name"] == "Жилой дом" assert props0["year_built"] == 1998 assert props0["is_neighbor"] is True # floors=16 → height_m 48.0 assert body["features"][1]["properties"]["height_m"] == pytest.approx(48.0) # floors=None → height_m null, building_name пустой → null, year_built null props2 = body["features"][2]["properties"] assert props2["floors"] is None assert props2["height_m"] is None assert props2["building_name"] is None assert props2["year_built"] is None assert props2["is_neighbor"] is True # ── пустой результат (участок есть, соседей нет) → 200 empty ─────────────── def test_neighbor_buildings_empty_ok(client: TestClient) -> None: """Участок в БД есть (EXISTS=true), но соседей в радиусе нет → 200 пустой.""" app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=True)) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") assert r.status_code == 200, r.text body = r.json() assert body["type"] == "FeatureCollection" assert body["features"] == [] assert body["count"] == 0 assert body["truncated"] is False # ── участок не найден (нет geom) → 404 ───────────────────────────────────── def test_neighbor_buildings_parcel_not_found_404(client: TestClient) -> None: """Пустой результат + EXISTS-пробник вернул None (нет geom) → 404.""" app.dependency_overrides[get_db] = _override_db(_build_mock_db([], parcel_exists=False)) r = client.get("/api/v1/parcels/66:41:9999999:1/neighbor-buildings") assert r.status_code == 404 assert "не найдена" in r.json()["detail"] # ── truncated: >300 соседей → флаг + обрезка до 300 ──────────────────────── def test_neighbor_buildings_truncated(client: TestClient) -> None: """SQL LIMIT 301 → если пришёл 301, отдаём 300 + truncated=true.""" rows = [ _make_building_row(f"66:41:0204016:{i}", floors=(i % 20) + 1, distance_m=float(i)) for i in range(301) ] app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") assert r.status_code == 200, r.text body = r.json() assert body["count"] == 300 assert len(body["features"]) == 300 assert body["truncated"] is True # ── radius валидация → 422 ───────────────────────────────────────────────── def test_neighbor_buildings_radius_too_small_422(client: TestClient) -> None: """radius_m=10 (< min 50) → 422.""" r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=10") assert r.status_code == 422 def test_neighbor_buildings_radius_too_large_422(client: TestClient) -> None: """radius_m=2000 (> max 1000) → 422.""" r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=2000") assert r.status_code == 422 def test_neighbor_buildings_radius_passthrough(client: TestClient) -> None: """radius_m из query отражается в ответе (и внутри — в bind SQL).""" app.dependency_overrides[get_db] = _override_db(_build_mock_db([_make_building_row()])) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings?radius_m=500") assert r.status_code == 200, r.text assert r.json()["radius_m"] == 500 # ── битая геометрия (ST_AsGeoJSON NULL) пропускается, не роняет сцену ─────── def test_neighbor_buildings_skips_null_geometry(client: TestClient) -> None: rows = [ _make_building_row("66:41:0204016:100", geojson=None), # битый polygon _make_building_row("66:41:0204016:101", geojson=_POLYGON_GEOJSON), ] app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows)) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/neighbor-buildings") assert r.status_code == 200, r.text body = r.json() assert body["count"] == 1 assert body["features"][0]["properties"]["cad_num"] == "66:41:0204016:101"