From c8ab3e8be9a9a947929503400479a355e6dff982 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 18:04:37 +0500 Subject: [PATCH] fix(analyze): district KeyError 500 in #994 persist + revive by-bbox tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/api/v1/parcels.py | 2 +- .../app/services/exporters/layout_tz_pdf.py | 6 +- .../app/services/exporters/trade_in_pdf.py | 6 +- backend/tests/api/v1/test_parcel_by_bbox.py | 70 +++++++++++-------- backend/tests/conftest.py | 29 ++++++++ 5 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 backend/tests/conftest.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 0a0b7b14..b3250e1e 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 ) diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py index 96178212..116dd647 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -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( """ + # 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 diff --git a/backend/app/services/exporters/trade_in_pdf.py b/backend/app/services/exporters/trade_in_pdf.py index 761382c3..e043d013 100644 --- a/backend/app/services/exporters/trade_in_pdf.py +++ b/backend/app/services/exporters/trade_in_pdf.py @@ -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)]) diff --git a/backend/tests/api/v1/test_parcel_by_bbox.py b/backend/tests/api/v1/test_parcel_by_bbox.py index 170fbdeb..8fad6345 100644 --- a/backend/tests/api/v1/test_parcel_by_bbox.py +++ b/backend/tests/api/v1/test_parcel_by_bbox.py @@ -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" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..7b19365f --- /dev/null +++ b/backend/tests/conftest.py @@ -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