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, НЕ
|
# ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
|
||||||
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
|
# SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
|
||||||
_district_name = (
|
_district_name = (
|
||||||
result_payload["district"]["district_name"]
|
result_payload["district"].get("district_name")
|
||||||
if isinstance(result_payload.get("district"), dict)
|
if isinstance(result_payload.get("district"), dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ import datetime as dt
|
||||||
import html as _html
|
import html as _html
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from weasyprint import HTML
|
|
||||||
|
|
||||||
from app.schemas.parcel import BestLayoutsResponse
|
from app.schemas.parcel import BestLayoutsResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -156,6 +154,10 @@ def render_layout_tz_pdf(
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||||
|
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
pdf_bytes = HTML(string=html).write_pdf()
|
pdf_bytes = HTML(string=html).write_pdf()
|
||||||
logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes))
|
logger.info("Generated layout TZ PDF for cad %s: %d bytes", cad_num, len(pdf_bytes))
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import datetime as dt
|
||||||
import html as _html
|
import html as _html
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from weasyprint import CSS, HTML
|
|
||||||
|
|
||||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
from app.schemas.trade_in import AggregatedEstimate, AnalogLot
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -413,6 +411,10 @@ def generate_trade_in_pdf(estimate: AggregatedEstimate, input_snapshot: dict) ->
|
||||||
Returns:
|
Returns:
|
||||||
PDF bytes готовые для Response(media_type="application/pdf")
|
PDF bytes готовые для Response(media_type="application/pdf")
|
||||||
"""
|
"""
|
||||||
|
# WeasyPrint импортируем локально — тяжёлый; не нужен при импорте модуля
|
||||||
|
# (иначе ломает сбор pytest на хостах без native-libs, напр. macOS).
|
||||||
|
from weasyprint import CSS, HTML
|
||||||
|
|
||||||
html_str = _build_html(estimate, input_snapshot)
|
html_str = _build_html(estimate, input_snapshot)
|
||||||
css_str = _build_css()
|
css_str = _build_css()
|
||||||
pdf_bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=css_str)])
|
pdf_bytes = HTML(string=html_str).write_pdf(stylesheets=[CSS(string=css_str)])
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@
|
||||||
1. Валидный bbox → 200 + корректная структура ответа.
|
1. Валидный bbox → 200 + корректная структура ответа.
|
||||||
2. Некорректный bbox (min_lat >= max_lat) → 400.
|
2. Некорректный bbox (min_lat >= max_lat) → 400.
|
||||||
3. user_id передан → status заполняется из mock DB; без user_id → status=null.
|
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 __future__ import annotations
|
||||||
|
|
@ -15,8 +22,12 @@ from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
# RBAC-гейт требует X-Authenticated-User; admin есть в auth/roles.yaml (см. test_rbac).
|
||||||
|
_AUTH = {"X-Authenticated-User": "admin"}
|
||||||
|
|
||||||
|
|
||||||
def _make_db_row(
|
def _make_db_row(
|
||||||
cad_num: str = "66:41:0204016:10",
|
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:
|
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()
|
mock_db = MagicMock()
|
||||||
mappings_mock = MagicMock()
|
mappings_mock = MagicMock()
|
||||||
mappings_mock.all.return_value = rows
|
mappings_mock.all.return_value = rows
|
||||||
|
|
@ -47,6 +58,13 @@ def _build_mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||||||
return mock_db
|
return mock_db
|
||||||
|
|
||||||
|
|
||||||
|
def _override_db(db: MagicMock):
|
||||||
|
def _get_db_override():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
return _get_db_override
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client() -> TestClient:
|
def client() -> TestClient:
|
||||||
return TestClient(app)
|
return TestClient(app)
|
||||||
|
|
@ -57,17 +75,15 @@ def client() -> TestClient:
|
||||||
|
|
||||||
def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
|
def test_by_bbox_valid_returns_structure(client: TestClient) -> None:
|
||||||
rows = [_make_db_row()]
|
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 патчим (mock_db.execute() отдаёт те же rows на любой запрос,
|
||||||
# ЛЮБОЙ запрос, поэтому без патча latest_run_dates получил бы parcel-rows (без
|
# без патча latest_run_dates получил бы parcel-rows без created_at). Пустой dict
|
||||||
# created_at) → KeyError. Пустой dict = «участок не анализировался» → date None.
|
# = «участок не анализировался» → last_analysis_date None.
|
||||||
with (
|
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||||||
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(
|
resp = client.get(
|
||||||
"/api/v1/parcels/by-bbox",
|
"/api/v1/parcels/by-bbox",
|
||||||
|
headers=_AUTH,
|
||||||
params={
|
params={
|
||||||
"min_lat": 56.80,
|
"min_lat": 56.80,
|
||||||
"min_lon": 60.60,
|
"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()
|
body = resp.json()
|
||||||
assert "parcels" in body
|
assert "parcels" in body
|
||||||
assert "count" 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:
|
def test_by_bbox_invalid_bbox_returns_400(client: TestClient) -> None:
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
"/api/v1/parcels/by-bbox",
|
"/api/v1/parcels/by-bbox",
|
||||||
|
headers=_AUTH,
|
||||||
params={
|
params={
|
||||||
"min_lat": 56.90, # min >= max → ошибка
|
"min_lat": 56.90, # min >= max → ошибка
|
||||||
"min_lon": 60.60,
|
"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:
|
def test_by_bbox_status_overlay_with_user_id(client: TestClient) -> None:
|
||||||
rows = [_make_db_row(user_status="in_work")]
|
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 (
|
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||||||
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(
|
resp = client.get(
|
||||||
"/api/v1/parcels/by-bbox",
|
"/api/v1/parcels/by-bbox",
|
||||||
|
headers=_AUTH,
|
||||||
params={
|
params={
|
||||||
"min_lat": 56.80,
|
"min_lat": 56.80,
|
||||||
"min_lon": 60.60,
|
"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]
|
parcel = resp.json()["parcels"][0]
|
||||||
assert parcel["status"] == "in_work"
|
assert parcel["status"] == "in_work"
|
||||||
|
|
||||||
|
|
||||||
def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
|
def test_by_bbox_no_user_id_status_is_null(client: TestClient) -> None:
|
||||||
rows = [_make_db_row(user_status="favorite")] # DB вернёт статус
|
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 (
|
with patch("app.api.v1.parcels.latest_run_dates", return_value={}):
|
||||||
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(
|
resp = client.get(
|
||||||
"/api/v1/parcels/by-bbox",
|
"/api/v1/parcels/by-bbox",
|
||||||
|
headers=_AUTH,
|
||||||
params={
|
params={
|
||||||
"min_lat": 56.80,
|
"min_lat": 56.80,
|
||||||
"min_lon": 60.60,
|
"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]
|
parcel = resp.json()["parcels"][0]
|
||||||
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
|
# Без user_id статус принудительно null (endpoint не раскрывает чужие статусы)
|
||||||
assert parcel["status"] is None
|
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) ──
|
# ── 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-дата."""
|
"""#994: участок с раном в v_analysis_runs_latest → last_analysis_date = ISO-дата."""
|
||||||
client = TestClient(app)
|
|
||||||
rows = [_make_db_row()]
|
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)}
|
latest = {"66:41:0204016:10": datetime(2026, 6, 1, 14, 30, 0)}
|
||||||
|
|
||||||
with (
|
with patch("app.api.v1.parcels.latest_run_dates", return_value=latest):
|
||||||
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(
|
resp = client.get(
|
||||||
"/api/v1/parcels/by-bbox",
|
"/api/v1/parcels/by-bbox",
|
||||||
|
headers=_AUTH,
|
||||||
params={
|
params={
|
||||||
"min_lat": 56.80,
|
"min_lat": 56.80,
|
||||||
"min_lon": 60.60,
|
"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]
|
parcel = resp.json()["parcels"][0]
|
||||||
# created_at → .date().isoformat() (только дата, без времени), str|None per parcel.py:387
|
# created_at → .date().isoformat() (только дата, без времени), str|None per parcel.py:387
|
||||||
assert parcel["last_analysis_date"] == "2026-06-01"
|
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