gendesign/backend/tests/api/v1/test_parcels_forecast.py
Light1YT 99c9a130bc
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Successful in 46s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 46s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m30s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m14s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / build-worker (push) Successful in 4m6s
Deploy / deploy (push) Successful in 1m40s
feat(forecast): расширить горизонт прогноза до 24 мес (ТЗ §12.1)
API отвергал ?horizon=24 (422), хотя ТЗ §12.1 называет 6/12/18/24, а движок
УЖЕ считает 24 на каждом ране: _DEFAULT_HORIZONS=(6,12,18,24) во всех 6 точках
стека (orchestrator/forecast-task/demand_supply_forecast/scenarios/
special_indices/report_assembler), PIPELINE_HORIZON_MONTHS=24.
_hidden_release_fraction клампит h/18→1.0 на 24 (без переполнения),
future_supply._horizon_weight расширяет окно чисто — скрытых ≤18 потолков нет.
Чистое расширение валидатора-enum, не новая математика.

Backend: _ALLOWED_FORECAST_HORIZONS → {6,12,18,24}, Query/docstring/error-msg.
Frontend: HorizonSelector HORIZONS=[6,12,18,24] (тип horizon=number, union не нужен;
прочие потребители data-driven через meta.horizons/forecasts_by_horizon).
Тесты: API принимает 24/отвергает 30; движок-тесты доказывают h=24 осмыслен
(поля посчитаны, demand(24)>demand(18), hidden созрел, индексы в диапазонах).

Closes #944 (Q1 горизонт 24)
2026-06-13 17:27:35 +05:00

606 lines
25 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,24} приняты
(24 — ТЗ §12.1, Q1); невалидные (7, 0, 30) → 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.get_air_quality_cached", return_value=None),
patch("app.api.v1.parcels.get_weather_cached", return_value=None),
patch("app.api.v1.parcels.get_seasonal_weather_cached", 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,24} принимается → 200 + enqueue с этим горизонтом.
24 — ТЗ §12.1 (Q1): движок уже считает 24 на каждом ране (первый горизонт в
_DEFAULT_HORIZONS всего §9.x-стека), здесь проверяем что API его ПРИНИМАЕТ как
запрошенный primary и прокидывает в enqueue ровно как остальные.
"""
from app.core.db import get_db
for h in (6, 12, 18, 24):
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,24} (7, 0, 30) → 422 ДО enqueue (delay не вызывается).
30 (за новым потолком 24) тоже отклоняется — сетка дискретная, а не ge/le-диапазон:
guard от случайного расширения до неограниченного горизонта.
"""
from app.core.db import get_db
for h in (7, 0, 30):
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()
# ── GET /{cad}/forecast/export (#959, EPIC export — Markdown + JSON download) ────
_REPORT_FIXTURE = {
"schema_version": "1.0",
"advisory": True,
"exec_summary": {
"headline": "Строить комфорт-класс.",
"verdict": "Дефицит малых форматов на горизонте 12 мес.",
"key_numbers": {"deficit_index": -1.0, "overall_score": 0.42},
"overall_confidence": "low",
},
"future_market": {
"forecasts_by_horizon": [
{
"horizon_months": 12,
"deficit_index": -1.0,
"projected_demand_units": 2330.8,
"projected_supply_units": 42953.2,
"confidence": "low",
}
],
},
"meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18, 24]},
}
def test_export_forecast_md_returns_markdown() -> None:
"""format=md → 200 text/markdown + Content-Disposition .md, тело — Markdown отчёт."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
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/export?format=md")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/markdown")
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.md"')
# cad-двоеточия санитизированы в имени файла.
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
body = resp.text
assert body.lstrip().startswith("# ")
assert "Оценка advisory" in body
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_md_is_default_format() -> None:
"""Без format → default md (text/markdown)."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/markdown")
finally:
app.dependency_overrides.clear()
def test_export_forecast_json_returns_dict() -> None:
"""format=json → 200 application/json + attachment .json, тело = run.result dict."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=json")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "application/json"
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.json"')
assert resp.json() == _REPORT_FIXTURE
finally:
app.dependency_overrides.clear()
def test_export_forecast_tg_returns_inline_plain_text() -> None:
"""format=tg → 200 text/plain; charset=utf-8, INLINE (НЕТ attachment Content-Disposition)."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
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/export?format=tg")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "text/plain; charset=utf-8"
# Сниппет для копипаста — НЕ файл-download: attachment-заголовка быть не должно.
assert "content-disposition" not in {k.lower() for k in resp.headers}
body = resp.text
# Краткая сводка несёт вердикт + advisory-caveat.
assert "комфорт" in body
assert "Оценка advisory" in body
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_tg_no_run_returns_404() -> None:
"""format=tg + рана нет → 404 (как md/json export-вариант, НЕ 202/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", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=tg")
assert resp.status_code == 404, resp.text
assert "посчитан" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_export_forecast_docx_returns_word_document() -> None:
"""format=docx → 200 wordprocessingml + attachment .docx, тело — байты .docx (PK)."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
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/export?format=docx")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
)
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.docx"')
# cad-двоеточия санитизированы в имени файла.
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
# OOXML/.docx — zip: первые 2 байта «PK».
assert resp.content[:2] == b"PK"
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_docx_no_run_returns_404() -> None:
"""format=docx + рана нет → 404 (как md/json export-вариант, НЕ 202/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", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx")
assert resp.status_code == 404, resp.text
assert "посчитан" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_export_forecast_pptx_returns_presentation() -> None:
"""format=pptx → 200 presentationml + attachment .pptx, тело — байты .pptx (PK)."""
from app.core.db import get_db
fake_run = MagicMock()
fake_run.result = _REPORT_FIXTURE
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/export?format=pptx")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == (
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
)
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.pptx"')
# cad-двоеточия санитизированы в имени файла.
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
# OOXML/.pptx — zip: первые 2 байта «PK».
assert resp.content[:2] == b"PK"
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_pptx_no_run_returns_404() -> None:
"""format=pptx + рана нет → 404 (как md/json/docx export-вариант, НЕ 202/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", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pptx")
assert resp.status_code == 404, resp.text
assert "посчитан" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_export_forecast_no_run_returns_404() -> None:
"""Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/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", return_value=None):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export")
assert resp.status_code == 404, resp.text
assert "посчитан" in resp.json()["detail"]
finally:
app.dependency_overrides.clear()
def test_export_forecast_invalid_format_returns_422() -> None:
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
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) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
assert resp.status_code == 422, resp.text
lrf.assert_not_called()
finally:
app.dependency_overrides.clear()