LIVE BUG (from #994, already merged): the analysis_runs persist call-site in analyze_parcel extracted district via result_payload["district"]["district_name"] — but a district dict without that key (real data: partial district lookup) raised KeyError OUTSIDE the best-effort SAVEPOINT (extraction is at the call site, not inside persist_analysis_run) → 500 on the LIVE /analyze endpoint. Fix: .get("district_name") → None instead of raising. Caught by reviving the analyze test suite (below). Test-infra (the suite was un-runnable, which is why the bug shipped): - Lazy-import WeasyPrint in layout_tz_pdf.py + trade_in_pdf.py (matches the existing report_pdf.py / snapshot_pdf.py pattern). The eager top-level imports made `app.main` (→ parcels/trade_in routers) un-importable on hosts without WeasyPrint native libs (e.g. macOS dev), breaking pytest COLLECTION of the whole api/v1 suite. WeasyPrint is still imported when a PDF is actually rendered. - tests/conftest.py: autouse fixture clears app.dependency_overrides after each test (anti-leak — a leaked get_db override caused real-DB connection attempts). - test_parcel_by_bbox.py: rewrite get_db mocking from patch("...get_db") (a no-op — FastAPI Depends holds the original ref) to app.dependency_overrides[get_db], add explicit X-Authenticated-User header (RBAC gate), patch latest_run_dates, + a new test asserting last_analysis_date from a latest run (#994). 5/5 green. NOTE: reviving collectability exposes PRE-EXISTING rot in other api/v1 suites (analyze/admin/best_layouts: RBAC-header + stale-assertion/mock drift) — those are NOT regressions from this PR (they were uncollectable before) and are tracked separately for a deliberate suite-rehab + CI test-gate effort. Refs #994.
205 lines
7.4 KiB
Python
205 lines
7.4 KiB
Python
"""Тесты GET /parcels/by-bbox (SF-B1).
|
||
|
||
Проверяют:
|
||
1. Валидный bbox → 200 + корректная структура ответа.
|
||
2. Некорректный bbox (min_lat >= max_lat) → 400.
|
||
3. user_id передан → status заполняется из mock DB; без user_id → status=null.
|
||
4. last_analysis_date заполняется из v_analysis_runs_latest (#994).
|
||
|
||
Механика моков: get_db переопределяем через app.dependency_overrides (НЕ
|
||
unittest.mock.patch — FastAPI Depends держит оригинальную ссылку, patch неэффективен).
|
||
latest_run_dates — обычная функция, вызывается напрямую → её патчим mock.patch.
|
||
Дефолтный X-Authenticated-User проставляет tests/conftest.py (RBAC-гейт). Очистку
|
||
dependency_overrides делает autouse-фикстура в conftest.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.core.db import get_db
|
||
from app.main import app
|
||
|
||
# RBAC-гейт требует X-Authenticated-User; admin есть в auth/roles.yaml (см. test_rbac).
|
||
_AUTH = {"X-Authenticated-User": "admin"}
|
||
|
||
|
||
def _make_db_row(
|
||
cad_num: str = "66:41:0204016:10",
|
||
centroid_lat: float = 56.83,
|
||
centroid_lon: float = 60.64,
|
||
area_m2: float = 1200.0,
|
||
land_category: str | None = "land_residential",
|
||
user_status: str | None = None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"cad_num": cad_num,
|
||
"centroid_lat": centroid_lat,
|
||
"centroid_lon": centroid_lon,
|
||
"area_m2": area_m2,
|
||
"land_category": land_category,
|
||
"user_status": user_status,
|
||
}
|
||
|
||
|
||
def _build_mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||
"""Mock Session, возвращающий rows при execute().mappings().all()."""
|
||
mock_db = MagicMock()
|
||
mappings_mock = MagicMock()
|
||
mappings_mock.all.return_value = rows
|
||
execute_result = MagicMock()
|
||
execute_result.mappings.return_value = mappings_mock
|
||
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)
|
||
|
||
|
||
# ── Test 1: валидный bbox возвращает корректную структуру ──────────────────
|
||
|
||
|
||
def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
|
||
rows = [_make_db_row()]
|
||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||
|
||
# latest_run_dates патчим (mock_db.execute() отдаёт те же rows на любой запрос,
|
||
# без патча latest_run_dates получил бы parcel-rows без created_at). Пустой dict
|
||
# = «участок не анализировался» → last_analysis_date None.
|
||
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||
resp = client.get(
|
||
"/api/v1/parcels/by-bbox",
|
||
headers=_AUTH,
|
||
params={
|
||
"min_lat": 56.80,
|
||
"min_lon": 60.60,
|
||
"max_lat": 56.90,
|
||
"max_lon": 60.70,
|
||
"limit": 200,
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert "parcels" in body
|
||
assert "count" in body
|
||
assert "limit" in body
|
||
assert "bbox_area_km2" in body
|
||
assert body["count"] == 1
|
||
assert body["limit"] == 200
|
||
|
||
parcel = body["parcels"][0]
|
||
assert parcel["cad_num"] == "66:41:0204016:10"
|
||
assert parcel["centroid_lat"] == pytest.approx(56.83, abs=0.01)
|
||
assert parcel["centroid_lon"] == pytest.approx(60.64, abs=0.01)
|
||
assert parcel["area_m2"] == pytest.approx(1200.0)
|
||
assert parcel["land_category"] == "land_residential"
|
||
assert parcel["status"] is None # user_id не передан
|
||
assert parcel["last_analysis_date"] is None
|
||
|
||
|
||
# ── Test 2: некорректный bbox → 400 ───────────────────────────────────────
|
||
|
||
|
||
def test_by_bbox_invalid_bbox_returns_400(client: TestClient) -> None:
|
||
resp = client.get(
|
||
"/api/v1/parcels/by-bbox",
|
||
headers=_AUTH,
|
||
params={
|
||
"min_lat": 56.90, # min >= max → ошибка
|
||
"min_lon": 60.60,
|
||
"max_lat": 56.80,
|
||
"max_lon": 60.70,
|
||
},
|
||
)
|
||
assert resp.status_code == 400
|
||
assert "bbox" in resp.json()["detail"].lower()
|
||
|
||
|
||
# ── Test 3: user_id → status из БД; без user_id → status null ─────────────
|
||
|
||
|
||
def test_by_bbox_status_overlay_with_user_id(client: TestClient) -> None:
|
||
rows = [_make_db_row(user_status="in_work")]
|
||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||
|
||
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||
resp = client.get(
|
||
"/api/v1/parcels/by-bbox",
|
||
headers=_AUTH,
|
||
params={
|
||
"min_lat": 56.80,
|
||
"min_lon": 60.60,
|
||
"max_lat": 56.90,
|
||
"max_lon": 60.70,
|
||
"user_id": "user-abc-123",
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
parcel = resp.json()["parcels"][0]
|
||
assert parcel["status"] == "in_work"
|
||
|
||
|
||
def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
|
||
rows = [_make_db_row(user_status="favorite")] # DB вернёт статус
|
||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||
|
||
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||
resp = client.get(
|
||
"/api/v1/parcels/by-bbox",
|
||
headers=_AUTH,
|
||
params={
|
||
"min_lat": 56.80,
|
||
"min_lon": 60.60,
|
||
"max_lat": 56.90,
|
||
"max_lon": 60.70,
|
||
# user_id не передан
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
parcel = resp.json()["parcels"][0]
|
||
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
|
||
assert parcel["status"] is None
|
||
|
||
|
||
# ── Test 5: last_analysis_date заполняется из v_analysis_runs_latest (#994) ──
|
||
|
||
|
||
def test_by_bbox_last_analysis_date_from_latest_run(client: TestClient) -> None:
|
||
"""#994: участок с раном в v_analysis_runs_latest → last_analysis_date = ISO-дата."""
|
||
rows = [_make_db_row()]
|
||
app.dependency_overrides[get_db] = _override_db(_build_mock_db(rows))
|
||
latest = {"66:41:0204016:10": datetime(2026, 6, 1, 14, 30, 0)}
|
||
|
||
with patch("app.api.v1.parcels.latest_run_dates", return_value=latest):
|
||
resp = client.get(
|
||
"/api/v1/parcels/by-bbox",
|
||
headers=_AUTH,
|
||
params={
|
||
"min_lat": 56.80,
|
||
"min_lon": 60.60,
|
||
"max_lat": 56.90,
|
||
"max_lon": 60.70,
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
parcel = resp.json()["parcels"][0]
|
||
# created_at → .date().isoformat() (только дата, без времени), str|None per parcel.py:387
|
||
assert parcel["last_analysis_date"] == "2026-06-01"
|