Backend: GET /parcels/{cad}/neighbor-buildings?radius_m=300 → GeoJSON
footprint'ов cad_buildings вокруг участка (ST_DWithin geography, GIST,
EXPLAIN 28мс на плотном участке; NOT ST_Intersects с самим участком;
SimplifyPreserveTopology; LIMIT 300 по расстоянию + truncated; 404 только
при отсутствии геометрии участка). Свойства: floors, height_m (этаж 3 м),
building_name, year_built.
Frontend: hook useNeighborBuildings (TanStack) + серые низкополигональные
экструзии в Massing3DScene (0.55 opacity, fallback 6 м, cap 200, отдельная
Group с корректным dispose) + неинтерактивный слой в 2D-плане под
корпусами; cadNum прокинут из Section7Concept; standalone /concept без
кадастра работает как раньше. Подпись про НСПД-этажность при count>0.
244 lines
9.1 KiB
Python
244 lines
9.1 KiB
Python
"""Тесты 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"
|