gendesign/backend/tests/api/v1/test_parcel_report.py
bot-backend 034d9f9bca
Some checks failed
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 3m12s
Deploy / build-worker (push) Successful in 4m8s
Deploy / build-frontend (push) Successful in 4m46s
Deploy / deploy (push) Has been cancelled
feat(reports): Celery-джоба полного PDF-отчёта + кэш + 3 endpoint'а (#2259 PR-D) (#2287)
2026-07-03 11:13:06 +00:00

275 lines
10 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()