gendesign/backend/tests/api/v1/test_admin_cadastre.py
lekss361 13025c81ee feat(cadastre): bulk_harvest_quarter Celery task + grid-walker + saga state (#168 PR3/5)
Add bulk cadastre harvest pipeline:
- services/cadastre/bulk_harvest.py: async harvest_quarter() orchestrator (4 phases)
  + 7 upsert helpers (parcels/buildings/constructions/oncs/enks/zouit/quarter_stats)
  using CAST(:x AS jsonb) pattern, begin_nested() SAVEPOINT per grid-walk upsert
- services/cadastre/grid_geometry.py: quarter_bbox_3857 (PostGIS ST_Extent)
  + generate_grid_click_points (15x15 = 225 sub-bbox grid)
- workers/tasks/scrape_cadastre.py: bulk_harvest_quarter_task (acks_late=True,
  dont_autoretry_for NspdBulkWafError), enqueue_cadastre_harvest, cleanup_zombies
- api/v1/admin_cadastre.py: 5 endpoints behind AdminTokenAuth — create/list/get/cancel/resume
- Tests: 13 service unit + 8 API tests
2026-05-15 13:03:35 +03:00

304 lines
9.8 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.

"""Тесты для admin_cadastre.py API endpoints.
Использует FastAPI TestClient с mock DB и mock Celery tasks.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5).
# Если ветка ещё не rebased на main — тесты которые импортируют task помечаем skip.
_SCRAPE_CADASTRE_AVAILABLE = True
try:
import app.workers.tasks.scrape_cadastre
except ModuleNotFoundError:
_SCRAPE_CADASTRE_AVAILABLE = False
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)