gendesign/backend/tests/services/test_objective_backfill.py
lekss361 dbf31cc911
All checks were successful
Deploy / changes (push) Successful in 4s
Deploy / build-backend (push) Successful in 1m38s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m36s
Deploy / deploy (push) Successful in 38s
feat(etl): objective_complex_mapping backfill via pg_trgm (#203) (#214)
2026-05-16 12:47:09 +00:00

176 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты для 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