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