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