"""Тесты §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()