"""Тесты для obj_class_backfill.py (#38, Demand D6). Mock DB (unittest.mock) — без реального PostgreSQL. Проверяют преобразование DB-rows в кандидаты, порог auto-accept, idempotent-skip и price-inference. """ from __future__ import annotations from typing import Any from unittest.mock import MagicMock from app.services.etl.obj_class_backfill import ( AUTO_ACCEPT_THRESHOLD, ClassMatchCandidate, apply_class_matches, apply_price_inference, find_class_candidates, run_backfill, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _make_candidate( obj_id: int, comm_name: str, yandex_name: str, yandex_class: str, score: float, ) -> ClassMatchCandidate: return ClassMatchCandidate( domrf_obj_id=obj_id, domrf_comm_name=comm_name, yandex_name=yandex_name, yandex_obj_class=yandex_class, similarity_score=score, ) def _make_db_row( obj_id: int, comm_name: str, yandex_name: str, yandex_class: str, score: float ) -> Any: """Имитирует Row из find_class_candidates SELECT.""" return (obj_id, comm_name, yandex_name, yandex_class, score) def _savepoint_cm() -> MagicMock: cm = MagicMock() cm.__enter__ = MagicMock(return_value=None) cm.__exit__ = MagicMock(return_value=False) return cm # ── Test 1: find_class_candidates маппит rows в ClassMatchCandidate ────────── def test_find_class_candidates_maps_rows() -> None: """find_class_candidates корректно преобразует DB rows в кандидаты.""" mock_rows = [ _make_db_row(100, "Северный квартал", "ЖК Северный квартал", "COMFORT", 0.91), _make_db_row(200, "АЛЛЕГРО", "Аллегро", "BUSINESS", 0.74), ] mock_execute = MagicMock() mock_execute.all.return_value = mock_rows mock_db = MagicMock() mock_db.execute.return_value = mock_execute candidates = find_class_candidates(mock_db) assert len(candidates) == 2 assert candidates[0].domrf_obj_id == 100 assert candidates[0].yandex_obj_class == "COMFORT" assert candidates[0].similarity_score == 0.91 assert candidates[1].yandex_obj_class == "BUSINESS" mock_db.execute.assert_called_once() params = mock_db.execute.call_args[0][1] assert params["region_cd"] == 66 assert params["min_threshold"] == AUTO_ACCEPT_THRESHOLD # ── Test 2: dry_run не пишет в БД ──────────────────────────────────────────── def test_apply_class_matches_dry_run_no_writes() -> None: """dry_run=True возвращает projected-счётчик без обращения к БД.""" mock_db = MagicMock() candidates = [ _make_candidate(1, "ЖК А", "ЖК А", "COMFORT", 0.95), _make_candidate(2, "ЖК Б", "ЖК Б", "BUSINESS", 0.40), # ниже порога ] result = apply_class_matches(mock_db, candidates, dry_run=True) assert result["updated"] == 1 # только >= 0.6 assert result["skipped"] == 0 mock_db.execute.assert_not_called() mock_db.commit.assert_not_called() # ── Test 3: пишутся только кандидаты >= threshold ──────────────────────────── def test_apply_class_matches_threshold_gate() -> None: """Только кандидаты >= AUTO_ACCEPT_THRESHOLD проходят в UPDATE.""" mock_result = MagicMock() mock_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = _savepoint_cm() mock_db.execute.return_value = mock_result candidates = [ _make_candidate(10, "ЖК Альфа", "Альфа", "COMFORT", AUTO_ACCEPT_THRESHOLD), # ровно порог _make_candidate(11, "ЖК Бета", "Бета", "BUSINESS", 0.80), _make_candidate(12, "ЖК Гамма", "Гамма", "ELITE", 0.59), # ниже порога ] result = apply_class_matches(mock_db, candidates) assert result["updated"] == 2 assert result["skipped"] == 0 assert mock_db.execute.call_count == 2 # только два >= порога mock_db.commit.assert_called_once() # ── Test 4: idempotent skip (rowcount=0) ───────────────────────────────────── def test_apply_class_matches_idempotent_skip() -> None: """UPDATE rowcount=0 (класс уже проставлен) считается skipped.""" mock_result = MagicMock() mock_result.rowcount = 0 # WHERE obj_class IS NULL не сматчил → уже заполнено mock_db = MagicMock() mock_db.begin_nested.return_value = _savepoint_cm() mock_db.execute.return_value = mock_result candidates = [_make_candidate(99, "ЖК уже размечен", "уже", "COMFORT", 0.99)] result = apply_class_matches(mock_db, candidates) assert result["updated"] == 0 assert result["skipped"] == 1 mock_db.commit.assert_called_once() # ── Test 5: failure одной строки не рушит цикл (SAVEPOINT) ─────────────────── def test_apply_class_matches_row_failure_isolated() -> None: """Исключение на одной строке логируется, цикл продолжается (skipped++).""" mock_ok = MagicMock() mock_ok.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = _savepoint_cm() # Первая строка кидает, вторая — успех. mock_db.execute.side_effect = [RuntimeError("boom"), mock_ok] candidates = [ _make_candidate(1, "ЖК падающий", "пад", "COMFORT", 0.9), _make_candidate(2, "ЖК норм", "норм", "BUSINESS", 0.9), ] result = apply_class_matches(mock_db, candidates) assert result["updated"] == 1 assert result["skipped"] == 1 mock_db.commit.assert_called_once() # ── Test 6: price_inference dry_run считает кандидатов ─────────────────────── def test_apply_price_inference_dry_run_counts() -> None: """dry_run=True считает кандидатов SELECT'ом, ничего не пишет.""" mock_execute = MagicMock() mock_execute.all.return_value = [(1, "2026-01-01", "COMFORT"), (2, "2026-01-01", "BUSINESS")] mock_db = MagicMock() mock_db.execute.return_value = mock_execute result = apply_price_inference(mock_db, dry_run=True) assert result["updated"] == 2 mock_db.commit.assert_not_called() # ── Test 7: price_inference пишет per-row с SAVEPOINT ──────────────────────── def test_apply_price_inference_writes_rows() -> None: """price_inference UPDATE'ит каждого кандидата, суммирует rowcount.""" select_result = MagicMock() select_result.all.return_value = [(1, "2026-01-01", "COMFORT"), (2, "2026-01-01", "ELITE")] update_result = MagicMock() update_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = _savepoint_cm() # Первый execute — SELECT кандидатов, далее — UPDATE per row. mock_db.execute.side_effect = [select_result, update_result, update_result] result = apply_price_inference(mock_db) assert result["updated"] == 2 mock_db.commit.assert_called_once() # ── Test 8: run_backfill оркестрирует обе ступени ──────────────────────────── def test_run_backfill_orchestrates_both_stages() -> None: """run_backfill: trigram-match → price_inference, агрегирует счётчики.""" # SELECT кандидатов (find) → пусто, чтобы apply_class_matches ничего не писал; # затем price_inference SELECT вернёт 1 кандидата + UPDATE. find_result = MagicMock() find_result.all.return_value = [] # нет trigram-кандидатов price_select = MagicMock() price_select.all.return_value = [(5, "2026-01-01", "COMFORT")] update_result = MagicMock() update_result.rowcount = 1 mock_db = MagicMock() mock_db.begin_nested.return_value = _savepoint_cm() mock_db.execute.side_effect = [find_result, price_select, update_result] result = run_backfill(mock_db) assert result["yandex_updated"] == 0 assert result["yandex_skipped"] == 0 assert result["price_updated"] == 1