🔴 Critical from review-bot: column units_count doesn't exist в ekburg_construction_permits (mock tests прошли т.к. db.execute мокается; на проде psycopg.errors.UndefinedColumn для каждого /analyze). Removes (7 refs): - SQL SELECT не выбирает units_count - dict build, PermitsSummary.rve_total_units - RecentPermit.units_count Pydantic field - PermitsSummary.rve_total_units field - 2 fixture rows assertions 🟡 Dedupe _quarter_prefix → reuse existing _quarter_cad (computed ранее для market_price block). Tests adjusted, ruff clean.
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""Тесты для recent_permits_in_quarter в POST /api/v1/parcels/{cad_num}/analyze (#105 Phase 5).
|
||
|
||
Покрывает:
|
||
1. analyze с quarter где есть РНС/РВЭ → recent_permits non-empty + summary calculated
|
||
2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...}
|
||
3. analyze 404 на invalid cad (no regression)
|
||
|
||
Порядок db.execute calls в analyze_parcel (после #105 Phase 5):
|
||
0. UNION ALL geom + source → .mappings().first()
|
||
1. WKT query → .mappings().first()
|
||
2. District → .mappings().first()
|
||
3. POI rows → .mappings().all()
|
||
4. Competitor rows → .mappings().all()
|
||
5. Pipeline rows → .mappings().all()
|
||
6. Centroid lat/lon → .mappings().first()
|
||
7. Noise rows → .mappings().all()
|
||
8. Hydrology → .mappings().all()
|
||
9. Utilities → .mappings().all()
|
||
10. Market trend → .mappings().first()
|
||
11. Zoning (begin_nested) → .mappings().first()
|
||
12. Success recommendation (begin_nested) → .mappings().all()
|
||
13. Market price (begin_nested) → .mappings().first()
|
||
14. Recent permits (begin_nested) → .mappings().all() ← NEW #105
|
||
15. _geotech_risk (industrial count) → .scalar()
|
||
16. _neighbors_summary (neighbor_rows) → .mappings().all()
|
||
17. _neighbors_summary (overlap_row) → .mappings().first()
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||
|
||
_CAD = "66:41:0204016:10" # cad_num с 4 частями — quarter prefix = "66:41:0204016"
|
||
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
|
||
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
|
||
|
||
|
||
# ── Mock factories ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
||
"""Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа."""
|
||
m = MagicMock()
|
||
m.__getitem__ = lambda self, k: data[k]
|
||
m.get = lambda k, default=None: data.get(k, default)
|
||
return m
|
||
|
||
|
||
def _make_db_for_analyze(
|
||
geom_found: bool = True,
|
||
permits_rows: list[dict[str, Any]] | None = None,
|
||
) -> MagicMock:
|
||
"""Сконструировать mock DB Session для analyze_parcel.
|
||
|
||
permits_rows=None → пустой список (нет разрешений в квартале).
|
||
permits_rows=[...] → список разрешений для агрегации.
|
||
"""
|
||
db = MagicMock()
|
||
|
||
geom_row = (
|
||
_make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
|
||
if geom_found
|
||
else None
|
||
)
|
||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||
district_row = _make_mapping(
|
||
{
|
||
"district_name": "Октябрьский",
|
||
"median_price_per_m2": 120000,
|
||
"dist_to_center": 1500.0,
|
||
}
|
||
)
|
||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||
|
||
raw_permits = [_make_mapping(p) for p in (permits_rows or [])]
|
||
|
||
call_idx = [0]
|
||
responses: list[Any] = [
|
||
("first", geom_row), # 0: geom UNION ALL
|
||
("first", wkt_row), # 1: WKT
|
||
("first", district_row), # 2: district
|
||
("all", []), # 3: POI rows
|
||
("all", []), # 4: competitor rows
|
||
("all", []), # 5: pipeline rows
|
||
("first", centroid_row), # 6: centroid
|
||
("all", []), # 7: noise rows
|
||
("all", []), # 8: hydrology rows
|
||
("all", []), # 9: utilities rows
|
||
("first", None), # 10: market trend
|
||
("first", None), # 11: zoning (begin_nested)
|
||
("all", []), # 12: success recommendation (begin_nested)
|
||
("first", None), # 13: market price (begin_nested)
|
||
("all", raw_permits), # 14: recent permits (begin_nested) ← #105
|
||
("scalar", 0), # 15: geotech_risk
|
||
("all", []), # 16: neighbors
|
||
("first", None), # 17: overlap
|
||
]
|
||
|
||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||
idx = call_idx[0]
|
||
call_idx[0] += 1
|
||
if idx >= len(responses):
|
||
r = MagicMock()
|
||
r.mappings.return_value.first.return_value = None
|
||
r.mappings.return_value.all.return_value = []
|
||
r.scalar.return_value = 0
|
||
return r
|
||
kind, data = responses[idx]
|
||
r = MagicMock()
|
||
r.mappings.return_value.first.return_value = data
|
||
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
||
r.scalar.return_value = data if kind == "scalar" else 0
|
||
return r
|
||
|
||
db.execute.side_effect = _execute_side_effect
|
||
|
||
ctx = MagicMock()
|
||
ctx.__enter__ = MagicMock(return_value=ctx)
|
||
ctx.__exit__ = MagicMock(return_value=False)
|
||
db.begin_nested.return_value = ctx
|
||
|
||
return db
|
||
|
||
|
||
def _override_db(db: MagicMock):
|
||
def _get_db_override():
|
||
yield db
|
||
|
||
return _get_db_override
|
||
|
||
|
||
_PATCHES = [
|
||
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
|
||
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
|
||
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
|
||
patch(
|
||
"app.api.v1.parcels.get_quarter_dump_data",
|
||
return_value={
|
||
"nspd_zoning": None,
|
||
"nspd_zouit_overlaps": [],
|
||
"nspd_engineering_nearby": [],
|
||
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
|
||
},
|
||
),
|
||
patch("app.api.v1.parcels.compute_velocity", return_value=None),
|
||
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
|
||
]
|
||
|
||
|
||
def _start_patches() -> None:
|
||
for p in _PATCHES:
|
||
p.start()
|
||
|
||
|
||
def _stop_patches() -> None:
|
||
for p in _PATCHES:
|
||
p.stop()
|
||
|
||
|
||
# ── Тесты ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_recent_permits_found_in_quarter() -> None:
|
||
"""analyze с квартала где есть РНС/РВЭ → recent_permits non-empty + summary calculated."""
|
||
from app.core.db import get_db
|
||
|
||
rns_permit: dict[str, Any] = {
|
||
"permit_type": "RNS",
|
||
"permit_number": "66-RNS-2024-001",
|
||
"issue_date": dt.date(2024, 6, 15),
|
||
"developer_name": "Атомстрой",
|
||
"developer_inn": "6671234567",
|
||
"object_name": "МКД ЖК Тест",
|
||
"object_type": "МКД",
|
||
"construction_address": "г. Екатеринбург, ул. Ленина, 1",
|
||
"total_area_sqm": 45000.0,
|
||
}
|
||
rve_permit: dict[str, Any] = {
|
||
"permit_type": "RVE",
|
||
"permit_number": "66-RVE-2025-002",
|
||
"issue_date": dt.date(2025, 3, 10),
|
||
"developer_name": "Атомстрой",
|
||
"developer_inn": "6671234567",
|
||
"object_name": "МКД ЖК Тест (1 оч.)",
|
||
"object_type": "МКД",
|
||
"construction_address": "г. Екатеринбург, ул. Ленина, 1",
|
||
"total_area_sqm": None,
|
||
}
|
||
|
||
db = _make_db_for_analyze(permits_rows=[rns_permit, rve_permit])
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
_start_patches()
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
|
||
assert "recent_permits_in_quarter" in body
|
||
assert "permits_summary" in body
|
||
|
||
permits = body["recent_permits_in_quarter"]
|
||
assert len(permits) == 2
|
||
|
||
rns = next(p for p in permits if p["permit_type"] == "RNS")
|
||
assert rns["permit_number"] == "66-RNS-2024-001"
|
||
assert rns["developer_name"] == "Атомстрой"
|
||
assert rns["issue_date"] == "2024-06-15"
|
||
assert rns["total_area_sqm"] == pytest.approx(45000.0)
|
||
|
||
rve = next(p for p in permits if p["permit_type"] == "RVE")
|
||
assert rve["issue_date"] == "2025-03-10"
|
||
assert "units_count" not in rve
|
||
|
||
summary = body["permits_summary"]
|
||
assert summary["rns_count"] == 1
|
||
assert summary["rve_count"] == 1
|
||
assert summary["rns_total_area_sqm"] == pytest.approx(45000.0)
|
||
assert "rve_total_units" not in summary
|
||
assert len(summary["by_developer"]) == 1
|
||
assert summary["by_developer"][0]["name"] == "Атомстрой"
|
||
assert summary["by_developer"][0]["permits_count"] == 2
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
_stop_patches()
|
||
|
||
|
||
def test_recent_permits_empty_quarter() -> None:
|
||
"""analyze с quarter без permits → recent_permits=[], summary с нулями."""
|
||
from app.core.db import get_db
|
||
|
||
db = _make_db_for_analyze(permits_rows=[])
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
_start_patches()
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
|
||
assert "recent_permits_in_quarter" in body
|
||
assert body["recent_permits_in_quarter"] == []
|
||
|
||
assert "permits_summary" in body
|
||
summary = body["permits_summary"]
|
||
assert summary["rns_count"] == 0
|
||
assert summary["rve_count"] == 0
|
||
assert summary["rns_total_area_sqm"] == pytest.approx(0.0)
|
||
assert "rve_total_units" not in summary
|
||
assert summary["by_developer"] == []
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
_stop_patches()
|
||
|
||
|
||
def test_recent_permits_invalid_cad_no_regression() -> None:
|
||
"""analyze с несуществующим cad → 202 или 404 (no regression от Phase 5)."""
|
||
from app.core.db import get_db
|
||
|
||
db = _make_db_for_analyze(geom_found=False)
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
_start_patches()
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post("/api/v1/parcels/00:00:0000000:0/analyze")
|
||
assert resp.status_code in (
|
||
202,
|
||
404,
|
||
), f"Ожидали 202 или 404 для неизвестного cad, получили {resp.status_code}: {resp.text}"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
_stop_patches()
|