All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m15s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 14m57s
Оркестратор build_full_report собирает §1-§7 + карты из analyze/forecast
ранов (реальная schema форсайта = "1.0"), best-effort дотягивает
connection-capacity (§3-резервы + сети рядом) и server-side концепцию
(поток POST /concepts: ConceptInput comfort → generate), рендерит
WeasyPrint'ом (локальный import — web-процесс native не тянет) на volume
/app/reports с метадата-раном report-pdf-1.0. Кэш по (analyze_run_id,
forecast_run_id) с проверкой существования файла (сирота → ре-рендер).
Запись атомарная (tmp+os.replace), имя файла из whitelist-санитизации
кад.номера. Celery task on-demand (time_limit 600), endpoint'ы POST
/{cad}/report (202/200) + status + download (дата из generated_at).
Deploy: bind-mount ./reports:/app/reports (backend+worker) + mkdir/chown
uid1000 в deploy.yml (docker создал бы root:root → PermissionError →
вечный building). reports/ в .gitignore. api-types регенерён (+219).
Refs #2259
275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""Тесты 3 эндпоинтов полного PDF-отчёта в parcels API (эпик #2259 PR-D).
|
||
|
||
Покрывает:
|
||
• POST /{cad}/report: нет analyze-рана → 404; enqueue → 202 {status:"building"};
|
||
готовый кэш (файл на диске) → 200 {status:"ready"} без повторного enqueue;
|
||
провал брокера → 503.
|
||
• GET /{cad}/report/status: none (нет analyze) / building (analyze есть, файла нет) /
|
||
ready (метадата-ран + файл на диске).
|
||
• GET /{cad}/report/download: файл на диске → 200 application/pdf + attachment;
|
||
отчёт не готов → 404.
|
||
|
||
Стратегия mock (зеркало test_parcels_forecast.py): DB через dependency_overrides,
|
||
`latest_run_for` патчим, Celery `.delay` — на реальной таске (lazy import в хендлере
|
||
резолвит атрибут в момент вызова). RBAC обходится settings.testing=True (conftest).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
from app.services.exporters.full_report_pdf import REPORT_SCHEMA_VERSION
|
||
|
||
_CAD = "66:41:0204016:10"
|
||
_ANALYZE_SV = "analyze-1.0"
|
||
_FORECAST_SV = "1.0"
|
||
|
||
|
||
def _override_db(db: MagicMock):
|
||
def _get_db_override():
|
||
yield db
|
||
|
||
return _get_db_override
|
||
|
||
|
||
def _run(run_id: int, result: dict[str, Any] | None = None) -> MagicMock:
|
||
r = MagicMock()
|
||
r.id = run_id
|
||
r.result = result or {}
|
||
r.district = "Кировский"
|
||
r.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
|
||
return r
|
||
|
||
|
||
def _lrf_router(rows: dict[str, MagicMock | None]):
|
||
"""Фабрика side_effect для latest_run_for: row по kwargs['schema_version']."""
|
||
|
||
def _side(db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
|
||
return rows.get(schema_version)
|
||
|
||
return _side
|
||
|
||
|
||
# ── POST /{cad}/report ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_post_report_no_analyze_returns_404() -> None:
|
||
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=_lrf_router({_ANALYZE_SV: None})
|
||
):
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||
assert resp.status_code == 404, resp.text
|
||
assert "анализа" in resp.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_post_report_enqueues_and_returns_202() -> None:
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||
delay_mock = MagicMock()
|
||
try:
|
||
with (
|
||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||
):
|
||
# _cached_report_result: report-row None → кэша нет, delay вызывается.
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||
assert resp.status_code == 202, resp.text
|
||
body = resp.json()
|
||
assert body["status"] == "building"
|
||
assert body["analyze_run_at"] is not None
|
||
delay_mock.assert_called_once_with(_CAD)
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_post_report_cache_hit_returns_200_ready(tmp_path: Path) -> None:
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF-cached")
|
||
report_row = _run(
|
||
500,
|
||
{
|
||
"pdf_path": str(pdf),
|
||
"analyze_run_id": 101,
|
||
"forecast_run_id": 202,
|
||
"generated_at": "2026-07-03T09:00:00+00:00",
|
||
"size_bytes": 10,
|
||
},
|
||
)
|
||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
delay_mock = MagicMock()
|
||
try:
|
||
with (
|
||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||
):
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["status"] == "ready"
|
||
delay_mock.assert_not_called() # кэш есть → не enqueue'им
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_post_report_broker_down_returns_503() -> None:
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
|
||
try:
|
||
with (
|
||
patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)),
|
||
patch("app.workers.tasks.full_report.build_full_report_task.delay", delay_mock),
|
||
):
|
||
client = TestClient(app)
|
||
resp = client.post(f"/api/v1/parcels/{_CAD}/report")
|
||
assert resp.status_code == 503, resp.text
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ── GET /{cad}/report/status ──────────────────────────────────────────────────
|
||
|
||
|
||
def test_status_none_when_no_analyze() -> None:
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
rows = {_ANALYZE_SV: None, _FORECAST_SV: None}
|
||
try:
|
||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["status"] == "none"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_status_building_when_analyze_but_no_file() -> None:
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: None}
|
||
try:
|
||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["status"] == "building"
|
||
assert body["analyze_run_at"] is not None
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_status_ready_when_file_on_disk(tmp_path: Path) -> None:
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF")
|
||
report_row = _run(
|
||
500,
|
||
{
|
||
"pdf_path": str(pdf),
|
||
"analyze_run_id": 101,
|
||
"forecast_run_id": 202,
|
||
"generated_at": "2026-07-03T09:00:00+00:00",
|
||
"size_bytes": 4,
|
||
},
|
||
)
|
||
rows = {_ANALYZE_SV: _run(101), _FORECAST_SV: _run(202), REPORT_SCHEMA_VERSION: report_row}
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/status")
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["status"] == "ready"
|
||
assert body["report_generated_at"] == "2026-07-03T09:00:00+00:00"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
# ── GET /{cad}/report/download ────────────────────────────────────────────────
|
||
|
||
|
||
def test_download_returns_pdf_when_ready(tmp_path: Path) -> None:
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||
# generated_at — ВЧЕРАШНЯЯ дата (cache-hit со вчера): имя файла должно взять ЕЁ,
|
||
# НЕ today (иначе имя врёт про дату сборки — ревью PR-D п.3).
|
||
report_row = _run(
|
||
500,
|
||
{
|
||
"pdf_path": str(pdf),
|
||
"analyze_run_id": 101,
|
||
"forecast_run_id": 202,
|
||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||
"size_bytes": 16,
|
||
},
|
||
)
|
||
rows = {REPORT_SCHEMA_VERSION: report_row}
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||
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"')
|
||
assert f"gendesign_report_{_CAD.replace(':', '_')}_" in cd
|
||
# Дата в имени — из метадаты (2026-07-01), НЕ today.
|
||
assert "2026-07-01" in cd
|
||
assert resp.content == b"%PDF-1.7 payload"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_download_404_when_not_ready() -> None:
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
rows = {REPORT_SCHEMA_VERSION: None}
|
||
try:
|
||
with patch("app.api.v1.parcels.latest_run_for", side_effect=_lrf_router(rows)):
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download")
|
||
assert resp.status_code == 404, resp.text
|
||
assert "готов" in resp.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|