From 0135f3c7c4a161bb4290c047a78badf604022bda Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 08:13:32 +0300 Subject: [PATCH] feat(#104): add POST /admin/scrape/ekburg-permits for manual trigger Wraps refresh_all / refresh_year Celery tasks behind the existing AdminTokenAuth gate so the table can be populated on demand after first deploy instead of waiting for the monthly beat (1st of month). - TriggerEkburgPermitsRequest: year int|None, ge=2022 le=2030 - year=None -> refresh_all.apply_async() (scope all_years_2022_2026) - year=N -> refresh_year.apply_async(args=[N]) (scope year_N) - 4 smoke tests: all/year/invalid_year/no_token --- backend/app/api/v1/admin_scrape.py | 40 +++++++++ .../tests/api/v1/test_admin_ekburg_permits.py | 82 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 backend/tests/api/v1/test_admin_ekburg_permits.py diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index cd611f4a..d9606667 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -1209,3 +1209,43 @@ def list_runs( } for r in rows ] + + +# ── Ekburg construction permits (РНС/РВЭ ЕКБ) ──────────────────────────────── + + +class TriggerEkburgPermitsRequest(BaseModel): + year: int | None = Field( + default=None, + ge=2022, + le=2030, + description=( + "Если None — refresh_all (все 5 лет 2022-2026). " + "Иначе refresh_year(year) для конкретного года." + ), + ) + + +@router.post("/ekburg-permits") +def trigger_ekburg_permits( + payload: TriggerEkburgPermitsRequest, + _: AdminTokenAuth, +) -> dict[str, Any]: + """Manual trigger для синхронизации РНС/РВЭ ЕКБ из екатеринбург.рф (xlsx). + + Обычно запускается раз в месяц через beat (1-го числа 02:00 UTC). + Этот endpoint — для ad-hoc запуска (после первого деплоя или для smoke-теста). + + - year=None → refresh_all: скачивает 5 xlsx (2022-2026), ~3-8 мин. + - year=2026 → refresh_year(2026): один файл, ~1 мин. + """ + from app.workers.tasks.ekburg_permits_sync import refresh_all, refresh_year + + if payload.year is None: + result = refresh_all.apply_async() + scope = "all_years_2022_2026" + else: + result = refresh_year.apply_async(args=[payload.year]) + scope = f"year_{payload.year}" + + return {"task_id": result.id, "scope": scope, "queued_at": "now"} diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py new file mode 100644 index 00000000..a03c10be --- /dev/null +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -0,0 +1,82 @@ +"""Тесты для POST /admin/scrape/ekburg-permits. + +Проверяет: +- валидный запрос без year → scope all_years_2022_2026, task_id в ответе +- валидный запрос с year=2026 → scope year_2026 +- year < 2022 или > 2030 → 422 +- отсутствие X-Admin-Token → 401/503 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +ADMIN_TOKEN = "test-admin-token" +ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} +ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" + + +def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: + result = MagicMock() + result.id = task_id + return result + + +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_refresh_all_returns_task_id() -> None: + """POST без year → refresh_all queued, scope=all_years_2022_2026.""" + mock_result = _mock_task("task-all-001") + + with ( + patch("app.workers.tasks.ekburg_permits_sync.refresh_all") as mock_refresh_all, + patch("app.workers.tasks.ekburg_permits_sync.refresh_year"), + ): + mock_refresh_all.apply_async.return_value = mock_result + client = TestClient(app) + response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + + assert response.status_code == 200, response.text + body = response.json() + assert body["task_id"] == "task-all-001" + assert body["scope"] == "all_years_2022_2026" + assert "queued_at" in body + + +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_refresh_year_returns_task_id() -> None: + """POST year=2026 → refresh_year queued, scope=year_2026.""" + mock_result = _mock_task("task-year-002") + + with ( + patch("app.workers.tasks.ekburg_permits_sync.refresh_year") as mock_refresh_year, + patch("app.workers.tasks.ekburg_permits_sync.refresh_all"), + ): + mock_refresh_year.apply_async.return_value = mock_result + client = TestClient(app) + response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + + assert response.status_code == 200, response.text + body = response.json() + assert body["task_id"] == "task-year-002" + assert body["scope"] == "year_2026" + + +@pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) +@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) +def test_trigger_invalid_year_returns_422(bad_year: int) -> None: + """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" + client = TestClient(app) + response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + assert response.status_code == 422, f"year={bad_year} должен возвращать 422" + + +def test_trigger_no_token_returns_401_or_503() -> None: + """Без X-Admin-Token → 401 или 503.""" + client = TestClient(app) + response = client.post(ENDPOINT, json={}) + assert response.status_code in (401, 503), response.text