gendesign/backend/tests/services/test_obj_class_backfill.py
bot-backend 9c52e0b29f
Some checks failed
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / build-backend (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
feat(etl): housing-class normalization fallback via yandex_realty trigram match (#38) (#1911)
housing-class normalization fallback via yandex_realty trigram (#38): migration 169 + ETL + COALESCE consumers. Refs #38
2026-06-26 07:44:25 +00:00

227 lines
8.8 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.

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