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