feat(site-finder): weight profiles endpoints + analyze integration (#114 sub-PR 3/4)
Admin CRUD router + non-breaking analyze_parcel integration. 46 tests pass. Refs: #114
This commit is contained in:
parent
5aea78c2d8
commit
c8e510cbbe
4 changed files with 484 additions and 2 deletions
102
backend/app/api/v1/admin_weight_profiles.py
Normal file
102
backend/app/api/v1/admin_weight_profiles.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Admin endpoints для управления weight profiles.
|
||||||
|
|
||||||
|
Per #114 (Макс feedback: садики ≠ Мегамарт). Endpoints под X-Admin-Token.
|
||||||
|
|
||||||
|
GET /api/v1/admin/site-finder/weight-profiles?user_id=... → list
|
||||||
|
POST /api/v1/admin/site-finder/weight-profiles → create
|
||||||
|
GET /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → get one
|
||||||
|
PUT /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → update
|
||||||
|
DELETE /api/v1/admin/site-finder/weight-profiles/{id}?user_id= → delete
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.db import get_db
|
||||||
|
from app.core.deps import AdminTokenAuth
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
WeightProfile,
|
||||||
|
WeightProfileCreate,
|
||||||
|
WeightProfileUpdate,
|
||||||
|
create_profile,
|
||||||
|
delete_profile,
|
||||||
|
get_profile,
|
||||||
|
list_profiles,
|
||||||
|
update_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[WeightProfile])
|
||||||
|
def list_user_profiles(
|
||||||
|
user_id: Annotated[str, Query(min_length=1, description="user_id для фильтра профилей")],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: AdminTokenAuth,
|
||||||
|
) -> Any:
|
||||||
|
"""Список всех weight profiles для заданного user_id. Default-профиль первым."""
|
||||||
|
return list_profiles(db, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=WeightProfile, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user_profile(
|
||||||
|
payload: WeightProfileCreate,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: AdminTokenAuth,
|
||||||
|
) -> Any:
|
||||||
|
"""Создать новый weight profile.
|
||||||
|
|
||||||
|
При is_default=True снимает is_default у всех существующих профилей
|
||||||
|
пользователя в одной транзакции.
|
||||||
|
"""
|
||||||
|
return create_profile(db, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=WeightProfile)
|
||||||
|
def get_user_profile(
|
||||||
|
profile_id: int,
|
||||||
|
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: AdminTokenAuth,
|
||||||
|
) -> Any:
|
||||||
|
"""Получить один weight profile по id (scoped к user_id)."""
|
||||||
|
profile = get_profile(db, user_id, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=WeightProfile)
|
||||||
|
def update_user_profile(
|
||||||
|
profile_id: int,
|
||||||
|
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
|
||||||
|
payload: WeightProfileUpdate,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: AdminTokenAuth,
|
||||||
|
) -> Any:
|
||||||
|
"""PATCH-style обновление weight profile.
|
||||||
|
|
||||||
|
Передавай только изменяемые поля. Не переданные поля остаются прежними.
|
||||||
|
При is_default=True снимает is_default у остальных профилей пользователя.
|
||||||
|
"""
|
||||||
|
profile = update_profile(db, user_id, profile_id, payload)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user_profile(
|
||||||
|
profile_id: int,
|
||||||
|
user_id: Annotated[str, Query(min_length=1, description="Владелец профиля")],
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_: AdminTokenAuth,
|
||||||
|
) -> None:
|
||||||
|
"""Удалить weight profile. 404 если не найден."""
|
||||||
|
deleted = delete_profile(db, user_id, profile_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
|
@ -1017,6 +1017,14 @@ def analyze_parcel(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
response: Response,
|
response: Response,
|
||||||
|
profile_id: Annotated[
|
||||||
|
int | None,
|
||||||
|
Query(ge=1, description="Переопределить веса POI через конкретный weight profile"),
|
||||||
|
] = None,
|
||||||
|
profile_user_id: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="user_id для fallback на default-профиль пользователя"),
|
||||||
|
] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Анализ участка: близость к социалке + district context + конкуренты.
|
"""Анализ участка: близость к социалке + district context + конкуренты.
|
||||||
|
|
||||||
|
|
@ -1192,6 +1200,16 @@ def analyze_parcel(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 3b) Resolve effective POI weights (profile → user default → system)
|
||||||
|
from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights
|
||||||
|
|
||||||
|
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
|
||||||
|
_weights_source = (
|
||||||
|
"profile"
|
||||||
|
if profile_id is not None
|
||||||
|
else ("user_default" if profile_user_id is not None else "system")
|
||||||
|
)
|
||||||
|
|
||||||
# 4) Scoring: weighted sum с distance decay
|
# 4) Scoring: weighted sum с distance decay
|
||||||
score = 0.0
|
score = 0.0
|
||||||
by_category: dict[str, list[dict[str, Any]]] = {}
|
by_category: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
@ -1199,7 +1217,7 @@ def analyze_parcel(
|
||||||
factors_detailed: list[dict[str, Any]] = []
|
factors_detailed: list[dict[str, Any]] = []
|
||||||
for idx, p in enumerate(poi_rows):
|
for idx, p in enumerate(poi_rows):
|
||||||
cat: str = p["category"]
|
cat: str = p["category"]
|
||||||
w = _POI_WEIGHTS.get(cat, 0.0)
|
w = _effective_weights.get(cat, _POI_WEIGHTS.get(cat, 0.0))
|
||||||
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
# distance decay: 1.0 на 0м, 0.5 на ~500м, ~0 на 1000м
|
||||||
distance_m = float(p["distance_m"])
|
distance_m = float(p["distance_m"])
|
||||||
decay = max(0.0, 1.0 - distance_m / 1000.0)
|
decay = max(0.0, 1.0 - distance_m / 1000.0)
|
||||||
|
|
@ -1852,6 +1870,13 @@ def analyze_parcel(
|
||||||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||||||
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
||||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||||
|
# #114: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
|
"weights_profile": {
|
||||||
|
"source": _weights_source,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"user_id": profile_user_id,
|
||||||
|
"weights_applied": _effective_weights,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,16 @@ import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.v1 import admin_jobs, admin_leads, admin_scrape, analytics, concepts, parcels, photos
|
from app.api.v1 import (
|
||||||
|
admin_jobs,
|
||||||
|
admin_leads,
|
||||||
|
admin_scrape,
|
||||||
|
admin_weight_profiles,
|
||||||
|
analytics,
|
||||||
|
concepts,
|
||||||
|
parcels,
|
||||||
|
photos,
|
||||||
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,6 +46,11 @@ app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytic
|
||||||
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
|
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
|
||||||
app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"])
|
app.include_router(admin_jobs.router, prefix="/api/v1/admin/jobs", tags=["admin"])
|
||||||
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
|
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
|
||||||
|
app.include_router(
|
||||||
|
admin_weight_profiles.router,
|
||||||
|
prefix="/api/v1/admin/site-finder/weight-profiles",
|
||||||
|
tags=["admin", "site-finder"],
|
||||||
|
)
|
||||||
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
341
backend/tests/test_admin_weight_profiles.py
Normal file
341
backend/tests/test_admin_weight_profiles.py
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
"""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()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
Loading…
Add table
Reference in a new issue