gendesign/backend/tests/api/v1/test_parcels_forecast.py
Light1YT 579ad54202
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
feat(export): compact Telegram summary export for §22 forecast (#959)
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
2026-06-06 18:55:47 +00:00

503 lines
20 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()
# ── 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]},
}
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_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} → 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()