"""Тесты для admin_cadastre.py API endpoints. Использует FastAPI TestClient с mock DB и mock Celery tasks. """ from __future__ import annotations # scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5). # Используем importlib.util.find_spec вместо прямого import — иначе # `import app.workers...` пересоздаёт `app` как Python package и # перебивает FastAPI instance, привязанный в строке выше → AttributeError # на app.dependency_overrides. import importlib.util from typing import Any from unittest.mock import MagicMock, patch import pytest from fastapi.testclient import TestClient from app.main import app _SCRAPE_CADASTRE_AVAILABLE = ( importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None ) requires_scrape_cadastre = pytest.mark.skipif( not _SCRAPE_CADASTRE_AVAILABLE, reason=( "app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). " "Команда: git rebase origin/main" ), ) # Токен для тестов (не реальный) ADMIN_TOKEN = "test-admin-token" ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None): """Создать mock db session с преднастроенными ответами.""" db = MagicMock() # Результат для _resolve_quarters (SELECT cad_number FROM cad_quarters_geom) quarter_rows = [(q,) for q in (quarters or ["66:41:0303161", "66:41:0303162"])] # Mock execute chain для разных запросов def mock_execute(stmt, params=None): stmt_str = str(stmt) result = MagicMock() if "cad_quarters_geom" in stmt_str and "cad_number" in stmt_str: # _resolve_quarters query result.all.return_value = quarter_rows elif "cadastre_jobs" in stmt_str and "RETURNING job_id" in stmt_str: # _create_job INSERT result.scalar_one.return_value = 42 elif "cadastre_jobs" in stmt_str and "SELECT" in stmt_str: # get/list jobs result.mappings.return_value.all.return_value = [job_row] if job_row else [] result.mappings.return_value.first.return_value = job_row elif "RETURNING job_id" in stmt_str: # cancel/resume result.first.return_value = (42,) else: result.all.return_value = [] result.mappings.return_value.all.return_value = [] result.mappings.return_value.first.return_value = None result.scalar_one.return_value = 1 return result db.execute = mock_execute db.commit = MagicMock() return db def _mock_get_db(db): """FastAPI dependency override factory.""" def _get_db_override(): yield db return _get_db_override def _make_sample_job_row() -> dict[str, Any]: import datetime return { "job_id": 42, "name": "cadastre_pilot", "job_kind": "pilot", "scope": "pilot", "status": "queued", "triggered_by": "manual", "rate_ms": 333, "created_at": datetime.datetime(2026, 5, 15, 10, 0, 0), "started_at": None, "finished_at": None, "heartbeat_at": None, "targets_total": 50, "targets_done": 0, "targets_failed": 0, "targets_skipped": 0, "requests_count": 0, "waf_blocked_count": 0, "error": None, "phase_state": None, "raw_targets": {"quarters": ["66:41:0303161"]}, } # ── Tests ──────────────────────────────────────────────────────────────────── @requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" quarters_50 = [f"66:41:{i:07d}" for i in range(50)] db = _make_mock_db(quarters=quarters_50) from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: with patch("app.workers.tasks.scrape_cadastre.enqueue_cadastre_harvest") as mock_task: mock_task.apply_async = MagicMock() client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() assert "job_id" in body assert body["scope"] == "pilot" assert body["targets_total"] == 50 assert "estimate_minutes" in body finally: app.dependency_overrides.clear() @requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" db = _make_mock_db() from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: with patch("app.workers.tasks.scrape_cadastre.enqueue_cadastre_harvest") as mock_task: mock_task.apply_async = MagicMock() client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs", json={ "scope": "manual_list", "quarters": ["66:41:0303161", "66:41:0303162"], }, headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() assert body["targets_total"] == 2 finally: app.dependency_overrides.clear() @requires_scrape_cadastre @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" db = _make_mock_db() from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "manual_list"}, headers=ADMIN_HEADERS, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_list_jobs_returns_list() -> None: """GET /cadastre/jobs → список jobs.""" db = _make_mock_db(job_row=_make_sample_job_row()) # Переопределяем execute чтобы list query вернул список job_row = _make_sample_job_row() mock_result = MagicMock() mock_result.mappings.return_value.all.return_value = [job_row] db.execute = MagicMock(return_value=mock_result) from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: client = TestClient(app) response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS) assert response.status_code == 200, response.text body = response.json() assert isinstance(body, list) assert len(body) == 1 assert body[0]["job_id"] == 42 assert body[0]["scope"] == "pilot" assert "progress_pct" in body[0] finally: app.dependency_overrides.clear() @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_get_job_not_found_returns_404() -> None: """GET /cadastre/jobs/9999 → 404.""" db = MagicMock() mock_result = MagicMock() mock_result.mappings.return_value.first.return_value = None db.execute = MagicMock(return_value=mock_result) from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: client = TestClient(app) response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS) assert response.status_code == 404 finally: app.dependency_overrides.clear() @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_success() -> None: """POST /cadastre/jobs/42/cancel → {cancelled: true}.""" db = MagicMock() mock_result = MagicMock() mock_result.first.return_value = (42,) db.execute = MagicMock(return_value=mock_result) db.commit = MagicMock() from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/42/cancel", headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() assert body["cancelled"] is True assert body["job_id"] == 42 finally: app.dependency_overrides.clear() @patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_not_found_returns_404() -> None: """POST /cadastre/jobs/9999/cancel когда job не найден → 404.""" db = MagicMock() mock_result = MagicMock() mock_result.first.return_value = None # job не найден / уже cancelled db.execute = MagicMock(return_value=mock_result) db.commit = MagicMock() from app.core.db import get_db app.dependency_overrides[get_db] = _mock_get_db(db) try: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/9999/cancel", headers=ADMIN_HEADERS, ) assert response.status_code == 404 finally: app.dependency_overrides.clear() def test_create_job_no_token_returns_401() -> None: """Без X-Admin-Token → 401/503.""" client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, ) assert response.status_code in (401, 503)