"""Тесты для objective_backfill.py (#203). Использует mock DB (unittest.mock) — не требует реального PostgreSQL. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock, patch from app.services.etl.objective_backfill import ( AUTO_ACCEPT_THRESHOLD, REVIEW_THRESHOLD, MatchCandidate, auto_apply_matches, find_match_candidates, trigger_mv_refresh, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _make_candidate( obj_id: int, comm_name: str, project_name: str, score: float, dev_name: str | None = "ООО Девелопер", ) -> MatchCandidate: return MatchCandidate( domrf_obj_id=obj_id, domrf_comm_name=comm_name, domrf_dev_name=dev_name, objective_project_name=project_name, similarity_score=score, ) def _make_db_row(obj_id: int, comm_name: str, dev_name: str, project: str, score: float) -> Any: """Имитирует Row, возвращаемый SQLAlchemy db.execute().all().""" return (obj_id, comm_name, dev_name, project, score) # ── Test 1: find_match_candidates возвращает правильные MatchCandidate ──────── def test_find_match_candidates_returns_candidates() -> None: """find_match_candidates корректно преобразует DB rows в MatchCandidate.""" mock_rows = [ _make_db_row(100, "Северный квартал", "Брусника", "Северный квартал", 1.0), _make_db_row(200, "АЛЛЕГРО", "СЗ ГОРЖИЛСТРОЙ", "АЛЛЕГРО", 0.95), _make_db_row(300, "Некий ЖК", None, "Близкий ЖК", 0.72), ] mock_execute = MagicMock() mock_execute.all.return_value = mock_rows mock_db = MagicMock() mock_db.execute.return_value = mock_execute candidates = find_match_candidates(mock_db, only_unmapped=True) assert len(candidates) == 3 assert candidates[0].domrf_obj_id == 100 assert candidates[0].similarity_score == 1.0 assert candidates[0].domrf_dev_name == "Брусника" assert candidates[2].domrf_dev_name is None # NULL dev_name → None assert candidates[2].similarity_score == 0.72 # Убедимся, что db.execute вызывался с параметрами mock_db.execute.assert_called_once() call_kwargs = mock_db.execute.call_args[0][1] assert call_kwargs["only_unmapped"] is True assert call_kwargs["min_threshold"] == REVIEW_THRESHOLD # ── Test 2: auto_apply_matches dry_run не вставляет ────────────────────────── def test_auto_apply_matches_dry_run_no_inserts() -> None: """dry_run=True возвращает projected-счётчики без обращения к БД. auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0 — смысл dry-run в admin-endpoint'е именно показать оператору объём перед реальным insert. execute/commit при этом не вызываются. """ mock_db = MagicMock() candidates = [ _make_candidate(1, "ЖК А", "ЖК А", 0.95), # auto-accept _make_candidate(2, "ЖК Б", "ЖК Б", 0.70), # review queue _make_candidate(3, "ЖК В", "ЖК В", 0.65), # review queue ] result = auto_apply_matches(mock_db, candidates, dry_run=True) assert result["auto_accepted"] == 1 # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD assert result["review_queue"] == 2 assert result["skipped"] == 0 mock_db.execute.assert_not_called() mock_db.commit.assert_not_called() # ── Test 3: auto_apply_matches high-score inserts, low-score → review_queue ── def test_auto_apply_matches_inserts_high_score_only() -> None: """Только кандидаты >= AUTO_ACCEPT_THRESHOLD вставляются в БД.""" # Мокируем begin_nested как context manager savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) mock_execute_result = MagicMock() mock_execute_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = mock_execute_result high_score = AUTO_ACCEPT_THRESHOLD # ровно на пороге — должен вставляться low_score = REVIEW_THRESHOLD # ровно REVIEW_THRESHOLD — не auto-accept candidates = [ _make_candidate(10, "ЖК Альфа", "ЖК Альфа", high_score), _make_candidate(11, "ЖК Бета", "ЖК Бета", high_score + 0.05), _make_candidate(20, "ЖК Гамма", "ЖК Гамма", low_score), # review только ] result = auto_apply_matches(mock_db, candidates, threshold=AUTO_ACCEPT_THRESHOLD) assert result["auto_accepted"] == 2 assert result["review_queue"] == 1 assert mock_db.execute.call_count == 2 # вызывался только для high-score mock_db.commit.assert_called_once() # ── Test 4: trigger_mv_refresh делегирует в layout_velocity_refresh ────────── def test_trigger_mv_refresh_calls_helper() -> None: """trigger_mv_refresh вызывает refresh_layout_velocity с concurrently=True. refresh_layout_velocity импортируется лениво внутри trigger_mv_refresh, поэтому патчим по полному пути модуля-источника. """ mock_db = MagicMock() with patch( "app.services.site_finder.layout_velocity_refresh.refresh_layout_velocity", return_value=512, ) as mock_refresh: count = trigger_mv_refresh(mock_db) assert count == 512 mock_refresh.assert_called_once_with(mock_db, concurrently=True) # ── Test 5: ON CONFLICT — rowcount=0 считается как skipped ─────────────────── def test_auto_apply_matches_conflict_counted_as_skipped() -> None: """Если INSERT вернул rowcount=0 (ON CONFLICT DO NOTHING), считается skipped.""" savepoint_cm = MagicMock() savepoint_cm.__enter__ = MagicMock(return_value=None) savepoint_cm.__exit__ = MagicMock(return_value=False) mock_execute_result = MagicMock() mock_execute_result.rowcount = 0 # ON CONFLICT DO NOTHING mock_db = MagicMock() mock_db.begin_nested.return_value = savepoint_cm mock_db.execute.return_value = mock_execute_result candidates = [ _make_candidate(99, "Уже существующий ЖК", "Уже существующий ЖК", 0.99), ] result = auto_apply_matches(mock_db, candidates) assert result["auto_accepted"] == 0 assert result["skipped"] == 1 assert result["review_queue"] == 0