gendesign/backend/tests/api/v1/test_parcel_by_bbox.py
Light1YT 72eae3bc9b
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m42s
feat(parcels): persist analysis runs + real last_analysis_date (#994)
Persist each successful POST /analyze into analysis_runs (migration 127):
best-effort SAVEPOINT-wrapped INSERT in a thin analysis_runs repository, then
explicit db.commit() (get_db has no commit-on-success; SAVEPOINT RELEASE alone
does not persist — without the commit the row rolls back on request teardown,
a silent no-op caught in code review). A persist failure never breaks the
response or poisons the session. Result serialized via jsonable_encoder→
json.dumps (handles embedded Pydantic models/dates); confidence/status
normalized to satisfy the 127 CHECK constraints (unknown→NULL/'complete').

Populate by-bbox last_analysis_date from v_analysis_runs_latest via a single
batch query (no N+1), replacing the #307 placeholder. The read is best-effort
wrapped too — a view-missing deploy window or future drift must not 500 the
map (falls back to last_analysis_date=None).

Additive only — analyze response shape unchanged. Tests: by-bbox suite patches
latest_run_dates (mock-db returns same rows for any query) + new test asserts
ISO last_analysis_date from a latest run. Analyze mock-suite unaffected
(graceful side_effect overflow + best-effort persist absorb the extra INSERT).
Incidental: ruff-format fixed one pre-existing f-string spacing (line ~2391)
the format hook flags on touch.

Closes #994. Refs #961.
2026-06-03 17:36:01 +05:00

195 lines
6.6 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.
"""
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.main import app
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
@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()]
mock_db = _build_mock_db(rows)
# latest_run_dates патчим явно (#994): mock_db.execute() возвращает те же rows на
# ЛЮБОЙ запрос, поэтому без патча latest_run_dates получил бы parcel-rows (без
# created_at) → KeyError. Пустой dict = «участок не анализировался» → date None.
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
"limit": 200,
},
)
assert resp.status_code == 200
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",
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")]
mock_db = _build_mock_db(rows)
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get(
"/api/v1/parcels/by-bbox",
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
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 вернёт статус
mock_db = _build_mock_db(rows)
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value={}),
):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
# user_id не передан
},
)
assert resp.status_code == 200
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() -> None:
"""#994: участок с раном в v_analysis_runs_latest → last_analysis_date = ISO-дата."""
client = TestClient(app)
rows = [_make_db_row()]
mock_db = _build_mock_db(rows)
latest = {"66:41:0204016:10": datetime(2026, 6, 1, 14, 30, 0)}
with (
patch("app.api.v1.parcels.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value=latest),
):
resp = client.get(
"/api/v1/parcels/by-bbox",
params={
"min_lat": 56.80,
"min_lon": 60.60,
"max_lat": 56.90,
"max_lon": 60.70,
},
)
assert resp.status_code == 200
parcel = resp.json()["parcels"][0]
# created_at → .date().isoformat() (только дата, без времени), str|None per parcel.py:387
assert parcel["last_analysis_date"] == "2026-06-01"