All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / 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 1m25s
CI / openapi-codegen-check (pull_request) Successful in 2m37s
CI / backend-tests (pull_request) Successful in 15m23s
build_full_report_docx зеркалит PDF §1-§7 + альтернативы из ТЕХ ЖЕ исходных словарей (HTML не парсит; переиспользует PURE-хелперы full_report_html — числа PDF и DOCX совпадают by construction) с картами через add_picture (плашка при отсутствии/битом PNG). python-docx — только локальные импорты: web-процесс native не тянет. build_full_report пишет .docx рядом с PDF (те же карт-PNG, общий _atomic_write tmp+replace); порядок render→write→метадата: упавший DOCX не оставляет ран-сироту. Метадата +docx_path/docx_size_bytes. GET /report/download?format=pdf|docx (Literal, default pdf; путь строго из метадаты); legacy-раны без docx → 404 «пересоберите отчёт». api-types регенерён (+12/−4, query-параметр). Refs #2259
424 lines
16 KiB
Python
424 lines
16 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()
|
||
|
||
|
||
# ── GET /{cad}/report/download?format=docx (#2259 PR-F) ─────────────────────────
|
||
|
||
|
||
def test_download_docx_returns_word_when_ready(tmp_path: Path) -> None:
|
||
"""format=docx + docx_path на диске → 200 + Word media-type + .docx filename."""
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||
docx = tmp_path / "report.docx"
|
||
docx.write_bytes(b"PK\x03\x04 fake-docx")
|
||
report_row = _run(
|
||
500,
|
||
{
|
||
"pdf_path": str(pdf),
|
||
"docx_path": str(docx),
|
||
"analyze_run_id": 101,
|
||
"forecast_run_id": 202,
|
||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||
"size_bytes": 16,
|
||
"docx_size_bytes": 12,
|
||
},
|
||
)
|
||
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?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"')
|
||
assert "2026-07-01" in cd
|
||
assert resp.content == b"PK\x03\x04 fake-docx"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_download_default_format_is_pdf(tmp_path: Path) -> None:
|
||
"""format не задан → default pdf (даже если docx_path тоже есть)."""
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||
docx = tmp_path / "report.docx"
|
||
docx.write_bytes(b"PK\x03\x04 fake-docx")
|
||
report_row = _run(
|
||
500,
|
||
{
|
||
"pdf_path": str(pdf),
|
||
"docx_path": str(docx),
|
||
"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"
|
||
assert resp.content == b"%PDF-1.7 payload"
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_download_docx_404_when_legacy_run_has_no_docx(tmp_path: Path) -> None:
|
||
"""Старый ран (собран до PR-F) без docx_path → format=docx 404 «пересоберите»."""
|
||
from app.core.db import get_db
|
||
|
||
pdf = tmp_path / "report.pdf"
|
||
pdf.write_bytes(b"%PDF-1.7 payload")
|
||
# Метадата без docx_path — legacy отчёт, собранный до PR-F.
|
||
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?format=docx")
|
||
assert resp.status_code == 404, resp.text
|
||
assert "пересоберите" in resp.json()["detail"]
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_download_docx_404_when_file_missing(tmp_path: Path) -> None:
|
||
"""docx_path в метадате есть, но файл на диске исчез → format=docx 404."""
|
||
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),
|
||
"docx_path": str(tmp_path / "gone.docx"), # файла нет
|
||
"analyze_run_id": 101,
|
||
"forecast_run_id": 202,
|
||
"generated_at": "2026-07-01T09:00:00+00:00",
|
||
"size_bytes": 4,
|
||
},
|
||
)
|
||
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?format=docx")
|
||
assert resp.status_code == 404, resp.text
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
|
||
def test_download_invalid_format_returns_422() -> None:
|
||
"""Неизвестный format (не pdf/docx) → 422 (Literal-валидация FastAPI)."""
|
||
from app.core.db import get_db
|
||
|
||
db = MagicMock()
|
||
app.dependency_overrides[get_db] = _override_db(db)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.get(f"/api/v1/parcels/{_CAD}/report/download?format=xlsx")
|
||
assert resp.status_code == 422, resp.text
|
||
finally:
|
||
app.dependency_overrides.clear()
|