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.
329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""Тесты §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()
|