gendesign/backend/tests/api/v1/test_parcels_forecast.py
Light1YT b1c0ea1268
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
fix(site-finder): 5 дефектов из боевого фидбека analyze (#1736 #1737 #1738 #1739 #1740)
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy)
- #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис»
- #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом
- #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback
- #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит)

Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed.

Closes #1736
Closes #1737
Closes #1738
Closes #1739
Closes #1740
2026-06-18 11:47:21 +05:00

679 lines
28 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
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _weasyprint_native_libs_available() -> tuple[bool, str]:
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
Зеркало гварда из tests/services/exporters/test_report_pdf.py — пропускаем
PDF-рендер на dev, но прогоняем на CI (#1739).
"""
try:
from weasyprint import HTML
HTML(string="<p>x</p>").write_pdf()
except (OSError, ImportError) as e:
return False, str(e)
return True, ""
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
_skip_if_no_weasyprint = pytest.mark.skipif(
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)
# ── Константы ─────────────────────────────────────────────────────────────────
_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:
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
# здесь None (gate не применяется).
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
) 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_list[0]
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()
@_skip_if_no_weasyprint
def test_export_forecast_pdf_returns_pdf() -> None:
"""format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
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=pdf")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "application/pdf"
cd = resp.headers["content-disposition"]
assert "attachment;" in cd
assert cd.endswith('.pdf"')
# cad-двоеточия санитизированы в имени файла.
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
# WeasyPrint PDF начинается с маркера %PDF.
assert resp.content[:4] == b"%PDF"
# Читаем именно §22-схему "1.0".
_, kwargs = lrf.call_args
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
def test_export_forecast_pdf_no_run_returns_404() -> None:
"""format=pdf + рана нет → 404 (как md/json/docx/pptx 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=pdf")
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,pdf} → 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=xml")
assert resp.status_code == 422, resp.text
lrf.assert_not_called()
finally:
app.dependency_overrides.clear()