gendesign/backend/tests/api/v1/test_parcels_forecast.py
Light1YT 19584e0249
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m14s
feat(parcels): wire §22 forecast into analyze endpoint (3b-ii, #995)
Final forecast wiring — §22 reaches the API.

- horizon query param on POST /{cad}/analyze ({6,12,18}, default 12; 422 before any
  DB/compute work otherwise).
- best-effort fire-and-forget forecast_site_finder_report.delay(cad, horizon,
  created_by) after persist (lazy import, no api↔workers cycle); Celery/Redis down →
  forecast.status='unavailable', analyze still 200 (never blocks/500s). Adds additive
  result_payload['forecast'] = {status: pending|unavailable, horizon}.
- new read-only GET /{cad}/forecast: latest schema_version='1.0' run → 200
  {status:ready, run_id, created_at, report} else 202 {status:pending}; graceful on DB
  error (202, never 500). Mirrors fetch-status RBAC/style.

Additive only — existing analyze response keys unchanged. code-review APPROVE
(analyze uncrashable + additive; 422 pre-flight; GET reads '1.0' not 'analyze-1.0').
96 api tests pass; ruff clean. Refs #995 #992 #961.
2026-06-05 09:22:57 +05:00

329 lines
13 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.

"""Тесты §22-форсайта в parcels API (3b-ii, #995).
Покрывает:
• `horizon` query-param на POST /analyze: default 12; валидные {6,12,18} приняты;
невалидные (7, 0) → 422 ДО любой работы с БД.
• Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
• Enqueue failure: `.delay` бросает → analyze всё равно 200, forecast.status="unavailable",
исключение НЕ всплывает.
• GET /{cad}/forecast: "1.0"-ран есть (mock latest_run_for) → 200 + status=ready + report;
рана нет → 202 + status=pending.
Стратегия mock: как test_analyze_market_price.py — DB через dependency_overrides,
тяжёлые сервисы патчим. Celery НЕ дёргаем: патчим `.delay` на реальной таске
(lazy import внутри analyze_parcel резолвит атрибут модуля в момент вызова).
RBAC обходится через settings.testing=True (conftest).
"""
from __future__ import annotations
import datetime as dt
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi.testclient import TestClient
from app.main import app
# ── Константы ─────────────────────────────────────────────────────────────────
_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
# ── Mock factories (зеркалят test_analyze_market_price.py) ──────────────────────
def _make_mapping(data: dict[str, Any]) -> MagicMock:
"""Mock-строка (mapping) с __getitem__ + .get() для dict-like доступа."""
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.get = lambda k, default=None: data.get(k, default)
return m
def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
"""Mock DB Session, достаточный чтобы analyze_parcel дошёл до persist + enqueue."""
db = MagicMock()
geom_row = (
_make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
if geom_found
else None
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = _make_mapping(
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
sql = " ".join(str(args[0]).split()) if args else ""
first_val: Any = None
if "AS geom_geojson" in sql:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
r = MagicMock()
r.mappings.return_value.first.return_value = first_val
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
db.execute.side_effect = _execute_side_effect
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
_PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
},
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
]
def _start_patches() -> None:
for p in _PATCHES:
p.start()
def _stop_patches() -> None:
for p in _PATCHES:
p.stop()
# ── horizon query-param ─────────────────────────────────────────────────────────
def test_horizon_default_12_and_enqueued() -> None:
"""Без horizon → default 12 прокидывается в enqueue, forecast.status=pending."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["forecast"] == {"status": "pending", "horizon": 12}
delay_mock.assert_called_once_with(_CAD, 12, None)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_horizon_valid_values_accepted() -> None:
"""horizon ∈ {6,12,18} принимается → 200 + enqueue с этим горизонтом."""
from app.core.db import get_db
for h in (6, 12, 18):
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 200, f"horizon={h}: {resp.text}"
assert resp.json()["forecast"] == {"status": "pending", "horizon": h}
delay_mock.assert_called_once_with(_CAD, h, None)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_horizon_invalid_returns_422() -> None:
"""horizon вне {6,12,18} (7, 0) → 422 ДО enqueue (delay не вызывается)."""
from app.core.db import get_db
for h in (7, 0):
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 422, f"horizon={h}: {resp.text}"
delay_mock.assert_not_called()
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_enqueue_passes_created_by_header() -> None:
"""X-Authenticated-User прокидывается в .delay как created_by (3-й позиц. арг)."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
headers={"X-Authenticated-User": "alice"},
)
assert resp.status_code == 200, resp.text
delay_mock.assert_called_once_with(_CAD, 12, "alice")
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── Enqueue failure ──────────────────────────────────────────────────────────
def test_enqueue_failure_returns_200_unavailable() -> None:
""".delay бросает (broker down) → analyze всё равно 200, forecast.status=unavailable."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["forecast"] == {"status": "unavailable", "horizon": 12}
delay_mock.assert_called_once()
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_response_keys_unchanged_additive() -> None:
"""forecast — additive: ключевые существующие блоки analyze остаются на месте."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
# Существующие ключи (до 3b-ii) присутствуют + новый forecast добавлен.
for key in ("cad_num", "district", "score", "score_breakdown", "risks", "forecast"):
assert key in body, f"ключ {key} отсутствует в ответе analyze"
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── GET /{cad}/forecast ──────────────────────────────────────────────────────
def test_get_forecast_ready_when_run_present() -> None:
"""Есть "1.0"-ран → 200 + status=ready + run_id + report=run.result."""
from app.core.db import get_db
report_dict = {"schema_version": "1.0", "deficit": -1.0, "moi": 117}
fake_run = MagicMock()
fake_run.id = 66
fake_run.created_at = dt.datetime(2026, 6, 5, 7, 32, 0)
fake_run.result = report_dict
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "ready"
assert body["run_id"] == 66
assert body["report"] == report_dict
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_get_forecast_pending_when_no_run() -> None:
"""Рана нет (latest_run_for → None) → 202 + status=pending."""
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 202, resp.text
assert resp.json() == {"status": "pending"}
finally:
app.dependency_overrides.clear()
def test_get_forecast_db_error_returns_pending_not_500() -> None:
"""Ошибка БД на read-only поллинге → 202 pending (graceful, не 500)."""
from app.core.db import get_db
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")
):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 202, resp.text
assert resp.json() == {"status": "pending"}
finally:
app.dependency_overrides.clear()