269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""Unit tests для WeightProfile Pydantic models + resolve_weights.
|
||
|
||
Mock-based — без реальной БД. Проверяет:
|
||
- WeightProfileCreate: отклонение неизвестных категорий
|
||
- WeightProfileCreate: отклонение весов за пределами MIN/MAX
|
||
- WeightProfileCreate: корректные веса проходят валидацию
|
||
- WeightProfileUpdate: weights=None не вызывает ошибку
|
||
- resolve_weights: нет user_id и profile_id → системные дефолты
|
||
- resolve_weights: user_id задан, default-профиль есть → его веса
|
||
- resolve_weights: profile_id задан → его веса
|
||
- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from app.services.site_finder.weight_profiles import (
|
||
_SYSTEM_POI_WEIGHTS,
|
||
WeightProfile,
|
||
WeightProfileCreate,
|
||
WeightProfileUpdate,
|
||
resolve_weights,
|
||
)
|
||
|
||
# ── Validation tests ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_validate_unknown_category() -> None:
|
||
"""Неизвестная POI-категория → ValidationError."""
|
||
with pytest.raises(ValidationError, match="Unknown POI categories"):
|
||
WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="test",
|
||
weights={"unknown": 1.0},
|
||
)
|
||
|
||
|
||
def test_validate_weight_bounds_too_high() -> None:
|
||
"""Вес > MAX_WEIGHT → ValidationError."""
|
||
with pytest.raises(ValidationError, match="out of bounds"):
|
||
WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="test",
|
||
weights={"school": 99.0},
|
||
)
|
||
|
||
|
||
def test_validate_weight_negative_too_low() -> None:
|
||
"""Вес < MIN_WEIGHT → ValidationError."""
|
||
with pytest.raises(ValidationError, match="out of bounds"):
|
||
WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="test",
|
||
weights={"tram_stop": -3.0},
|
||
)
|
||
|
||
|
||
def test_validate_valid_weights() -> None:
|
||
"""Корректные веса проходят валидацию без ошибок."""
|
||
profile = WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="my profile",
|
||
weights={"school": 1.5, "tram_stop": -0.5, "park": 2.9},
|
||
)
|
||
assert profile.weights["school"] == 1.5
|
||
assert profile.weights["tram_stop"] == -0.5
|
||
|
||
|
||
def test_validate_empty_weights() -> None:
|
||
"""Пустой dict weights допустим (используются системные дефолты)."""
|
||
profile = WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="empty",
|
||
weights={},
|
||
)
|
||
assert profile.weights == {}
|
||
|
||
|
||
def test_validate_boundary_values() -> None:
|
||
"""Граничные значения MIN_WEIGHT и MAX_WEIGHT включительно — допустимы."""
|
||
profile = WeightProfileCreate(
|
||
user_id="user-1",
|
||
profile_name="boundary",
|
||
weights={"school": -2.0, "park": 3.0},
|
||
)
|
||
assert profile.weights["school"] == -2.0
|
||
assert profile.weights["park"] == 3.0
|
||
|
||
|
||
def test_validate_update_weights_none() -> None:
|
||
"""WeightProfileUpdate с weights=None не вызывает ошибку валидации."""
|
||
upd = WeightProfileUpdate(weights=None)
|
||
assert upd.weights is None
|
||
|
||
|
||
def test_validate_update_unknown_category() -> None:
|
||
"""WeightProfileUpdate с неизвестной категорией → ValidationError."""
|
||
with pytest.raises(ValidationError, match="Unknown POI categories"):
|
||
WeightProfileUpdate(weights={"bogus_category": 1.0})
|
||
|
||
|
||
# ── resolve_weights tests ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_resolve_weights_system_default() -> None:
|
||
"""Оба аргумента None → возвращаются системные веса."""
|
||
db = MagicMock()
|
||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||
assert result.source == "system"
|
||
# db не должен вызываться вообще
|
||
db.execute.assert_not_called()
|
||
|
||
|
||
def test_resolve_weights_system_default_returns_copy() -> None:
|
||
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
|
||
db = MagicMock()
|
||
result = resolve_weights(db, user_id=None, profile_id=None)
|
||
result.weights["school"] = 999.0
|
||
# Оригинал не изменён
|
||
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
|
||
|
||
|
||
def _make_profile_mock(weights: dict[str, float]) -> WeightProfile:
|
||
"""Вспомогательная функция: создать WeightProfile с заданными weights."""
|
||
from datetime import UTC, datetime
|
||
|
||
return WeightProfile(
|
||
id=1,
|
||
user_id="user-1",
|
||
profile_name="test",
|
||
weights=weights,
|
||
is_default=True,
|
||
description=None,
|
||
created_at=datetime.now(UTC),
|
||
updated_at=datetime.now(UTC),
|
||
)
|
||
|
||
|
||
def test_resolve_weights_uses_default_profile() -> None:
|
||
"""user_id задан, default-профиль найден → его веса."""
|
||
custom_weights = {"school": 2.0, "park": 0.5}
|
||
profile = _make_profile_mock(custom_weights)
|
||
|
||
db = MagicMock()
|
||
import app.services.site_finder.weight_profiles as wp_module
|
||
|
||
original = wp_module.get_default_profile
|
||
wp_module.get_default_profile = lambda _db, uid: profile
|
||
|
||
try:
|
||
result = resolve_weights(db, user_id="user-1", profile_id=None)
|
||
finally:
|
||
wp_module.get_default_profile = original
|
||
|
||
assert result.weights == custom_weights
|
||
assert result.source == "user_default"
|
||
|
||
|
||
def test_resolve_weights_uses_specific_profile() -> None:
|
||
"""profile_id задан → веса конкретного профиля."""
|
||
custom_weights = {"kindergarten": 1.8, "metro_stop": 1.0}
|
||
profile = _make_profile_mock(custom_weights)
|
||
|
||
db = MagicMock()
|
||
import app.services.site_finder.weight_profiles as wp_module
|
||
|
||
original = wp_module.get_profile
|
||
wp_module.get_profile = lambda _db, uid, pid: profile
|
||
|
||
try:
|
||
result = resolve_weights(db, user_id="user-1", profile_id=42)
|
||
finally:
|
||
wp_module.get_profile = original
|
||
|
||
assert result.weights == custom_weights
|
||
assert result.source == "profile"
|
||
|
||
|
||
def test_resolve_weights_profile_not_found_fallback() -> None:
|
||
"""Профиль не найден (get_profile → None) → системные дефолты."""
|
||
db = MagicMock()
|
||
import app.services.site_finder.weight_profiles as wp_module
|
||
|
||
original_get = wp_module.get_profile
|
||
original_default = wp_module.get_default_profile
|
||
wp_module.get_profile = lambda _db, uid, pid: None
|
||
wp_module.get_default_profile = lambda _db, uid: None
|
||
|
||
try:
|
||
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||
finally:
|
||
wp_module.get_profile = original_get
|
||
wp_module.get_default_profile = original_default
|
||
|
||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||
# #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли
|
||
assert result.source == "system"
|
||
|
||
|
||
def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
||
"""Профиль найден но weights пустой → системные дефолты."""
|
||
profile = _make_profile_mock({}) # пустые weights
|
||
|
||
db = MagicMock()
|
||
import app.services.site_finder.weight_profiles as wp_module
|
||
|
||
original_default = wp_module.get_default_profile
|
||
wp_module.get_default_profile = lambda _db, uid: profile
|
||
|
||
try:
|
||
result = resolve_weights(db, user_id="user-1", profile_id=None)
|
||
finally:
|
||
wp_module.get_default_profile = original_default
|
||
|
||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||
assert result.source == "system"
|
||
|
||
|
||
def test_resolve_weights_profile_id_without_owner_is_not_profile(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется.
|
||
|
||
Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 —
|
||
source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он
|
||
-0.4). Метка обязана быть 'system', а промах — попасть в warning.
|
||
"""
|
||
db = MagicMock()
|
||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||
result = resolve_weights(db, user_id=None, profile_id=1)
|
||
|
||
assert result.source == "system"
|
||
assert result.weights == _SYSTEM_POI_WEIGHTS
|
||
assert "profile_id=1" in caplog.text
|
||
db.execute.assert_not_called() # профиль даже не искали
|
||
|
||
|
||
def test_resolve_weights_missing_profile_falls_to_user_default_not_profile(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
"""#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль.
|
||
|
||
Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не
|
||
видна. Метка должна сказать 'user_default', а не 'profile'.
|
||
"""
|
||
import app.services.site_finder.weight_profiles as wp_module
|
||
|
||
default_profile = _make_profile_mock({"school": 2.0})
|
||
db = MagicMock()
|
||
original_get = wp_module.get_profile
|
||
original_default = wp_module.get_default_profile
|
||
wp_module.get_profile = lambda _db, uid, pid: None
|
||
wp_module.get_default_profile = lambda _db, uid: default_profile
|
||
try:
|
||
with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"):
|
||
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||
finally:
|
||
wp_module.get_profile = original_get
|
||
wp_module.get_default_profile = original_default
|
||
|
||
assert result.source == "user_default"
|
||
assert result.weights == {"school": 2.0}
|
||
assert "profile_id=999" in caplog.text
|