gendesign/backend/tests/api/v1/test_parcel_report.py
bot-backend 1ed4104105
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
feat(exporters): DOCX-вариант полного отчёта ПТИЦА (#2259 R2)
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
2026-07-04 01:04:25 +05:00

424 lines
16 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.

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