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.
503 lines
20 KiB
Python
503 lines
20 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()
|
||
|
||
|
||
# ── 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()
|