gendesign/backend/tests/api/v1/test_parcel_report.py
bot-backend 747d752b2d
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
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 / openapi-codegen-check (pull_request) Successful in 2m35s
CI / backend-tests (pull_request) Successful in 14m43s
fix(reports): HEAD-поддержка на /report/download — qa поймал 405
qa-смоук #2338: FastAPI НЕ добавляет HEAD к @router.get автоматически —
фронтовый HEAD-preflight DOCX получал 405 (allow: GET), abортился, и UI
молча не скачивал файл вовсе (curl GET при этом отдавал валидный DOCX
2.08 МБ). Явный @router.head (include_in_schema=False): FileResponse
при HEAD нативно отдаёт только заголовки. +2 теста (200 headers-only,
404 симметрично GET).
2026-07-04 03:57:36 +05:00

477 lines
18 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()
def test_download_head_returns_headers_only(tmp_path: Path) -> None:
"""HEAD на download — 200 с заголовками БЕЗ тела (preflight фронта, #2338).
FastAPI не добавляет HEAD к @router.get автоматически — прод отдавал 405,
HEAD-preflight абортился и UI молча не скачивал DOCX. Явный @router.head чинит.
"""
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(
510,
{
"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",
},
)
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.head(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"
)
assert resp.content == b""
finally:
app.dependency_overrides.clear()
def test_download_head_404_when_not_ready() -> None:
"""HEAD для участка без готового отчёта — 404 (симметрично GET)."""
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({})):
client = TestClient(app)
resp = client.head(f"/api/v1/parcels/{_CAD}/report/download")
assert resp.status_code == 404
finally:
app.dependency_overrides.clear()