"""Tests для admin weight profiles endpoints. Покрывает: - GET /api/v1/admin/site-finder/weight-profiles → 200 + list - POST → 201 + created profile - GET /{id} → 200 / 404 - PUT /{id} → 200 / 404 - DELETE /{id} → 204 / 404 - 401 при отсутствии X-Admin-Token - 422 при невалидных weights (неизвестная категория, вес вне диапазона) Mock-based: get_db переопределяется через dependency override. """ from __future__ import annotations from datetime import UTC, datetime from unittest.mock import MagicMock import pytest from fastapi.testclient import TestClient from app.core.db import get_db from app.main import app from app.services.site_finder.weight_profiles import WeightProfile _ADMIN_TOKEN = "test-admin-token" _HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} _NOW = datetime.now(UTC) def _make_profile( profile_id: int = 1, user_id: str = "user-1", profile_name: str = "Test", weights: dict | None = None, is_default: bool = False, ) -> WeightProfile: return WeightProfile( id=profile_id, user_id=user_id, profile_name=profile_name, weights=weights or {"school": 1.5, "park": 1.8}, is_default=is_default, description=None, created_at=_NOW, updated_at=_NOW, ) @pytest.fixture() def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) return TestClient(app) @pytest.fixture() def mock_db() -> MagicMock: return MagicMock() def _override_db(mock: MagicMock): def _get_db_override(): yield mock app.dependency_overrides[get_db] = _get_db_override return mock def _clear_overrides(): app.dependency_overrides.pop(get_db, None) # ── GET list ─────────────────────────────────────────────────────────────────── def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + пустой список.""" mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [], ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-x"}, headers=_HEADERS, ) assert r.status_code == 200 assert r.json() == [] finally: _clear_overrides() def test_list_returns_profiles( client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?user_id= → 200 + список профилей.""" profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: profiles, ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 200 body = r.json() assert len(body) == 2 assert body[0]["id"] == 1 finally: _clear_overrides() # ── POST create ──────────────────────────────────────────────────────────────── def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST создаёт профиль, возвращает его со статусом 201.""" created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.create_profile", lambda db, payload: created, ) r = client_with_token.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Семейный", "weights": {"school": 2.0, "park": 1.5}, "is_default": False, }, headers=_HEADERS, ) assert r.status_code == 201 body = r.json() assert body["id"] == 42 assert body["profile_name"] == "Семейный" assert body["weights"]["school"] == 2.0 finally: _clear_overrides() def test_create_validation_unknown_category(client_with_token: TestClient) -> None: """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" r = client_with_token.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES }, headers=_HEADERS, ) assert r.status_code == 422 def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: """POST с весом вне [-2, 3] → 422.""" r = client_with_token.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"school": 99.0}, }, headers=_HEADERS, ) assert r.status_code == 422 # ── GET one ──────────────────────────────────────────────────────────────────── def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id}?user_id= → 200.""" profile = _make_profile(7) mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: profile, ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles/7", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["id"] == 7 finally: _clear_overrides() def test_get_profile_not_found( client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET /{id} несуществующего профиля → 404.""" mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: None, ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 404 finally: _clear_overrides() # ── PUT update ───────────────────────────────────────────────────────────────── def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} → 200 + обновлённый профиль.""" updated = _make_profile(3, profile_name="Обновлённый") mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: updated, ) r = client_with_token.put( "/api/v1/admin/site-finder/weight-profiles/3", params={"user_id": "user-1"}, json={"profile_name": "Обновлённый"}, headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["profile_name"] == "Обновлённый" finally: _clear_overrides() def test_update_profile_not_found( client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """PUT /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: None, ) r = client_with_token.put( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, json={"profile_name": "X"}, headers=_HEADERS, ) assert r.status_code == 404 finally: _clear_overrides() # ── DELETE ───────────────────────────────────────────────────────────────────── def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} → 204.""" mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: True, ) r = client_with_token.delete( "/api/v1/admin/site-finder/weight-profiles/5", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 204 finally: _clear_overrides() def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: False, ) r = client_with_token.delete( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 404 finally: _clear_overrides() # ── include_system preset seed (Issue #114 / PR #229) ───────────────────────── def test_list_include_system_calls_with_system( client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" system_profile = _make_profile( 100, user_id="__system__", profile_name="Комфорт", weights={"park": 1.8, "school": 1.5} ) user_profile = _make_profile(1, user_id="user-1", profile_name="Мой профиль") mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: [user_profile, system_profile], ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1", "include_system": "true"}, headers=_HEADERS, ) assert r.status_code == 200 body = r.json() assert len(body) == 2 profile_names = {p["profile_name"] for p in body} assert "Комфорт" in profile_names assert "Мой профиль" in profile_names finally: _clear_overrides() def test_list_without_include_system_does_not_call_with_system( client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET без include_system → list_profiles (только пользовательские профили).""" user_profile = _make_profile(1, user_id="user-1") called_with_system = [] mock = MagicMock() _override_db(mock) try: monkeypatch.setattr( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [user_profile], ) monkeypatch.setattr( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: called_with_system.append(True) or [], ) r = client_with_token.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, headers=_HEADERS, ) assert r.status_code == 200 assert len(r.json()) == 1 # list_profiles_with_system НЕ должен вызываться без include_system=true assert called_with_system == [], "вызван list_profiles_with_system без флага" finally: _clear_overrides() def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> None: """list_profiles_with_system передаёт system_user_id='__system__' в запрос.""" from app.services.site_finder.weight_profiles import SYSTEM_USER_ID, list_profiles_with_system captured_params: list[dict] = [] class FakeResult: def mappings(self) -> FakeResult: return self def all(self) -> list: return [] class FakeDb: def execute(self, query: object, params: dict) -> FakeResult: captured_params.append(params) return FakeResult() list_profiles_with_system(FakeDb(), user_id="user-test") assert len(captured_params) == 1 assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID assert captured_params[0]["user_id"] == "user-test" # ── Auth ─────────────────────────────────────────────────────────────────────── _TOKEN_REMOVED_REASON = ( "App-level X-Admin-Token header удалён 2026-05-23 (см. docstring " "app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, " "двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. " "В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта " "security-решения. Тест проверял удалённое поведение." ) @pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: """Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) client = TestClient(app) r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, # без headers — нет X-Admin-Token ) assert r.status_code == 401 @pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: """Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) client = TestClient(app) r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, headers={"X-Admin-Token": "wrong-token"}, ) assert r.status_code == 401