fix(analyze): district KeyError 500 (#994) + revive by-bbox tests #1029
5 changed files with 78 additions and 35 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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
29
backend/tests/conftest.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue