267 lines
9.4 KiB
Python
267 lines
9.4 KiB
Python
"""Тесты для GET /{cad_num}/connection-points (issue #115).
|
||
|
||
Использует FastAPI TestClient с mock DB — без реального Postgres.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
_VALID_CAD = "66:41:0204016:10"
|
||
_QUARTER = "66:41:0204016"
|
||
|
||
|
||
def _mock_get_db(db: Any):
|
||
"""FastAPI dependency override factory."""
|
||
|
||
def _get_db_override():
|
||
yield db
|
||
|
||
return _get_db_override
|
||
|
||
|
||
def _make_structure(distance_m: float = 120.5) -> dict[str, Any]:
|
||
return {
|
||
"name": "ТП-101",
|
||
"type": "Трансформаторная подстанция",
|
||
"cad_num": None,
|
||
"distance_to_boundary_m": distance_m,
|
||
"geometry_geojson": {"type": "Point", "coordinates": [60.6, 56.8]},
|
||
"readable_address": None,
|
||
"raw_props": {"name": "ТП-101"},
|
||
"source": "nspd_36328",
|
||
}
|
||
|
||
|
||
def _make_zouit_overlap() -> dict[str, Any]:
|
||
return {
|
||
"reg_numb_border": "RN-001",
|
||
"type_zone": "Охранная зона ЛЭП",
|
||
"subcategory": 5,
|
||
"intersects_parcel": True,
|
||
"geometry_geojson": {"type": "Polygon", "coordinates": [[]]},
|
||
"raw_props": {"type_zone": "Охранная зона ЛЭП"},
|
||
"source": "nspd_37578",
|
||
}
|
||
|
||
|
||
def _make_summary(
|
||
nearest: float | None = 120.5,
|
||
in_zone: bool = False,
|
||
zones_count: int = 0,
|
||
total: int = 1,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"nearest_structure_distance_m": nearest,
|
||
"in_protection_zone": in_zone,
|
||
"protection_zones_intersecting": zones_count,
|
||
"total_structures_in_radius": total,
|
||
}
|
||
|
||
|
||
def _make_full_response(
|
||
structures: list[dict[str, Any]] | None = None,
|
||
overlaps: list[dict[str, Any]] | None = None,
|
||
summary: dict[str, Any] | None = None,
|
||
dump_available: bool = True,
|
||
dump_fetched_at: str | None = "2026-05-01T12:00:00+00:00",
|
||
) -> dict[str, Any]:
|
||
if structures is None:
|
||
structures = [_make_structure()]
|
||
if overlaps is None:
|
||
overlaps = []
|
||
if summary is None:
|
||
summary = _make_summary(total=len(structures))
|
||
return {
|
||
"engineering_structures": structures,
|
||
"zouit_engineering_overlaps": overlaps,
|
||
"summary": summary,
|
||
"dump_available": dump_available,
|
||
"dump_fetched_at": dump_fetched_at,
|
||
}
|
||
|
||
|
||
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_connection_points_no_dump_returns_empty() -> None:
|
||
"""Квартал без dump → dump_available=false, пустые массивы, 200 OK."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _mock_get_db(db)
|
||
|
||
empty_response = {
|
||
"engineering_structures": [],
|
||
"zouit_engineering_overlaps": [],
|
||
"summary": {
|
||
"nearest_structure_distance_m": None,
|
||
"in_protection_zone": False,
|
||
"protection_zones_intersecting": 0,
|
||
"total_structures_in_radius": 0,
|
||
},
|
||
"dump_available": False,
|
||
"dump_fetched_at": None,
|
||
}
|
||
|
||
try:
|
||
with patch(
|
||
"app.api.v1.parcels.get_connection_points",
|
||
return_value=empty_response,
|
||
):
|
||
client = TestClient(app)
|
||
response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points")
|
||
|
||
assert response.status_code == 200, response.text
|
||
body = response.json()
|
||
assert body["dump_available"] is False
|
||
assert body["engineering_structures"] == []
|
||
assert body["zouit_engineering_overlaps"] == []
|
||
assert body["summary"]["total_structures_in_radius"] == 0
|
||
assert body["summary"]["nearest_structure_distance_m"] is None
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_connection_points_parcel_not_found_404() -> None:
|
||
"""cad_num не найден в БД (ValueError из сервиса) → 404."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _mock_get_db(db)
|
||
|
||
try:
|
||
with patch(
|
||
"app.api.v1.parcels.get_connection_points",
|
||
side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"),
|
||
):
|
||
client = TestClient(app)
|
||
response = client.get("/api/v1/parcels/66:41:9999999:1/connection-points")
|
||
|
||
assert response.status_code == 404
|
||
assert "не найден" in response.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_connection_points_filters_by_radius() -> None:
|
||
"""Фичи за radius_m не попадают в ответ (сервис фильтрует).
|
||
|
||
Мокаем сервис: при radius_m=100 возвращаем только близкую структуру,
|
||
при radius_m=500 — обе. Проверяем что endpoint передаёт radius_m в сервис.
|
||
"""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _mock_get_db(db)
|
||
|
||
close_only = _make_full_response(structures=[_make_structure(distance_m=80.0)])
|
||
far_included = _make_full_response(
|
||
structures=[_make_structure(distance_m=80.0), _make_structure(distance_m=450.0)],
|
||
summary=_make_summary(nearest=80.0, total=2),
|
||
)
|
||
|
||
try:
|
||
with patch("app.api.v1.parcels.get_connection_points") as mock_svc:
|
||
mock_svc.return_value = close_only
|
||
client = TestClient(app)
|
||
r100 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=100")
|
||
assert r100.status_code == 200
|
||
assert r100.json()["summary"]["total_structures_in_radius"] == 1
|
||
# Проверяем что radius_m=100 передан в сервис
|
||
call_kwargs = mock_svc.call_args
|
||
assert call_kwargs[0][2] == 100 or call_kwargs[1].get("radius_m") == 100
|
||
|
||
mock_svc.return_value = far_included
|
||
r500 = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=500")
|
||
assert r500.status_code == 200
|
||
assert r500.json()["summary"]["total_structures_in_radius"] == 2
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_connection_points_zouit_intersects_flag() -> None:
|
||
"""zouit_engineering_overlaps содержат intersects_parcel=true и правильные поля."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _mock_get_db(db)
|
||
|
||
overlap = _make_zouit_overlap()
|
||
full = _make_full_response(
|
||
overlaps=[overlap],
|
||
summary=_make_summary(in_zone=True, zones_count=1),
|
||
)
|
||
|
||
try:
|
||
with patch(
|
||
"app.api.v1.parcels.get_connection_points",
|
||
return_value=full,
|
||
):
|
||
client = TestClient(app)
|
||
response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points")
|
||
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert body["summary"]["in_protection_zone"] is True
|
||
assert body["summary"]["protection_zones_intersecting"] == 1
|
||
overlaps = body["zouit_engineering_overlaps"]
|
||
assert len(overlaps) == 1
|
||
assert overlaps[0]["intersects_parcel"] is True
|
||
assert overlaps[0]["reg_numb_border"] == "RN-001"
|
||
assert overlaps[0]["source"] == "nspd_37578"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_summary_nearest_distance() -> None:
|
||
"""summary.nearest_structure_distance_m = расстояние до ближайшей структуры."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _mock_get_db(db)
|
||
|
||
s1 = _make_structure(distance_m=42.7)
|
||
s2 = _make_structure(distance_m=180.0)
|
||
full = _make_full_response(
|
||
structures=[s1, s2],
|
||
summary=_make_summary(nearest=42.7, total=2),
|
||
)
|
||
|
||
try:
|
||
with patch(
|
||
"app.api.v1.parcels.get_connection_points",
|
||
return_value=full,
|
||
):
|
||
client = TestClient(app)
|
||
response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points")
|
||
|
||
assert response.status_code == 200
|
||
body = response.json()
|
||
assert body["summary"]["nearest_structure_distance_m"] == pytest.approx(42.7)
|
||
assert body["summary"]["total_structures_in_radius"] == 2
|
||
assert body["engineering_structures"][0]["distance_to_boundary_m"] == pytest.approx(42.7)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_connection_points_radius_out_of_range_422() -> None:
|
||
"""radius_m=10 (< min 50) → 422 Unprocessable Entity."""
|
||
client = TestClient(app)
|
||
response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=10")
|
||
assert response.status_code == 422
|
||
|
||
|
||
def test_connection_points_radius_too_large_422() -> None:
|
||
"""radius_m=5000 (> max 2000) → 422 Unprocessable Entity."""
|
||
client = TestClient(app)
|
||
response = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-points?radius_m=5000")
|
||
assert response.status_code == 422
|