gendesign/backend/tests/test_admin_weight_profiles.py
lekss361 a6e4ff0407 feat(#114): seed 3 default weight presets + include_system API param
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.
2026-05-16 22:49:56 +03:00

428 lines
15 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.

"""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