176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""Тесты для 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 возвращает счётчики без обращения к БД (execute не вызывается)."""
|
||
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"] == 0
|
||
assert result["review_queue"] == 2
|
||
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
|