Add SQL migration 100_user_weight_profiles_default_seed.sql with system presets Эконом/Комфорт/Бизнес (user_id='__system__'). Migration is idempotent via ON CONFLICT DO UPDATE. Backend: - weight_profiles.py: add SYSTEM_USER_ID constant + list_profiles_with_system() - admin_weight_profiles.py: add include_system query param to GET list endpoint Tests: 3 new tests covering include_system flag and service sentinel behaviour.
428 lines
15 KiB
Python
428 lines
15 KiB
Python
"""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 ───────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Запрос без X-Admin-Token → 401 (или 503 если токен не задан в settings)."""
|
||
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
|
||
|
||
|
||
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Неверный X-Admin-Token → 401."""
|
||
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
|