gendesign/backend/tests/api/v1/test_parcel_by_bbox.py
Light1YT c8ab3e8be9
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m28s
Deploy / deploy (push) Successful in 1m44s
fix(analyze): district KeyError 500 in #994 persist + revive by-bbox tests
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.
2026-06-03 18:04:37 +05:00

205 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты 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"