fix(analyze): district KeyError 500 in #994 persist + revive by-bbox tests
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

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.
This commit is contained in:
Light1YT 2026-06-03 18:04:37 +05:00
parent 72eae3bc9b
commit c8ab3e8be9
5 changed files with 78 additions and 35 deletions

View file

@ -2655,7 +2655,7 @@ def analyze_parcel(
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
_district_name = (
result_payload["district"]["district_name"]
result_payload["district"].get("district_name")
if isinstance(result_payload.get("district"), dict)
else None
)

View file

@ -9,8 +9,6 @@ import datetime as dt
import html as _html
import logging
from weasyprint import HTML
from app.schemas.parcel import BestLayoutsResponse
logger = logging.getLogger(__name__)
@ -156,6 +154,10 @@ def render_layout_tz_pdf(
</body>
</html>"""
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
from weasyprint import HTML
pdf_bytes = HTML(string=html).write_pdf()
logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes))
return pdf_bytes

View file

@ -15,8 +15,6 @@ import datetime as dt
import html as _html
import logging
from weasyprint import CSS, HTML
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
logger = logging.getLogger(__name__)
@ -413,6 +411,10 @@ def generate_trade_in_pdf(estimate: AggregatedEstimate, input_snapshot: dict) ->
Returns:
PDF bytes готовые для Response(media_type="application/pdf")
"""
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
from weasyprint import CSS, HTML
html_str = _build_html(estimate, input_snapshot)
css_str = _build_css()
pdf_bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=css_str)])

View file

@ -4,6 +4,13 @@
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
@ -15,8 +22,12 @@ 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",
@ -37,7 +48,7 @@ def _make_db_row(
def _build_mock_db(rows: list[dict[str, Any]]) -> MagicMock:
"""Сконструировать mock Session, возвращающий rows при execute().mappings().all()."""
"""Mock Session, возвращающий rows при execute().mappings().all()."""
mock_db = MagicMock()
mappings_mock = MagicMock()
mappings_mock.all.return_value = rows
@ -47,6 +58,13 @@ def _build_mock_db(rows: list[dict[str, Any]]) -> MagicMock:
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)
@ -57,17 +75,15 @@ def client() -> TestClient:
def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
rows = [_make_db_row()]
mock_db = _build_mock_db(rows)
app.dependency_overrides[get_db] = _override_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={}),
):
# 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,
@ -77,7 +93,7 @@ def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
},
)
assert resp.status_code == 200
assert resp.status_code == 200, resp.text
body = resp.json()
assert "parcels" in body
assert "count" in body
@ -102,6 +118,7 @@ def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
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,
@ -118,14 +135,12 @@ def test_by_bbox_invalid_bbox_returns_400(client: TestClient) -> None:
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)
app.dependency_overrides[get_db] = _override_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={}),
):
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,
@ -135,21 +150,19 @@ def test_by_bbox_status_overlay_with_user_id(client: TestClient) -> None:
},
)
assert resp.status_code == 200
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 вернёт статус
mock_db = _build_mock_db(rows)
app.dependency_overrides[get_db] = _override_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={}),
):
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,
@ -159,7 +172,7 @@ def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
},
)
assert resp.status_code == 200
assert resp.status_code == 200, resp.text
parcel = resp.json()["parcels"][0]
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
assert parcel["status"] is None
@ -168,19 +181,16 @@ def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
# ── Test 5: last_analysis_date заполняется из v_analysis_runs_latest (#994) ──
def test_by_bbox_last_analysis_date_from_latest_run() -> None:
def test_by_bbox_last_analysis_date_from_latest_run(client: TestClient) -> None:
"""#994: участок с раном в v_analysis_runs_latest → last_analysis_date = ISO-дата."""
client = TestClient(app)
rows = [_make_db_row()]
mock_db = _build_mock_db(rows)
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.get_db", return_value=iter([mock_db])),
patch("app.api.v1.parcels.latest_run_dates", return_value=latest),
):
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,
@ -189,7 +199,7 @@ def test_by_bbox_last_analysis_date_from_latest_run() -> None:
},
)
assert resp.status_code == 200
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"

29
backend/tests/conftest.py Normal file
View file

@ -0,0 +1,29 @@
"""Shared pytest fixtures для backend-сьюта.
NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `X-Authenticated-User`
на любом non-public пути иначе 401. Тесты, бьющие по `app` через TestClient,
должны слать этот заголовок ЯВНО на запрос (напр. test_parcel_by_bbox), либо
тестировать именно no-auth путь (admin-тесты ждут 401 без заголовка). Глобально
заголовок НЕ инжектим это ломает тесты, проверяющие отказ авторизации.
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _clear_dependency_overrides():
"""Сбрасывать app.dependency_overrides после каждого теста (anti-leak).
Тест переопределяет get_db через app.dependency_overrides; без сброса override
течёт в следующий тест ложные коннекты к реальной БД / флейки. Импорт app
локальный (ленивый) не тянем тяжёлый app.main при сборе тестов без него.
"""
yield
try:
from app.main import app
app.dependency_overrides.clear()
except Exception:
pass