kn-API не отдаёт obj_class для ~3032 ЖК Свердл, что ломает per-class velocity/absorption/weight. Добавлен fallback: - 169_obj_class_fallback.sql: obj_class_fallback + obj_class_source колонки, pg_trgm + GIN trgm индексы на LOWER(comm_name) и LOWER(yandex_realty_zk.name). - app/services/etl/obj_class_backfill.py: trigram-match comm_name ↔ yandex_realty_zk.name (similarity >= 0.6, CROSS JOIN LATERAL) → obj_class_source='yandex_match'; price-inference по yandex_realty_class_prices → 'price_inference'. Idempotent, SAVEPOINT per row. - Консьюмеры (analytics_queries / velocity / competitors): класс-фильтр и dominant_class читают COALESCE(obj_class, obj_class_fallback) — реальный класс в приоритете, поведение для размеченных ЖК не меняется. Внутренний backfill — response-схема (parcel.py / OpenAPI) не меняется. Refs #38
227 lines
8.8 KiB
Python
227 lines
8.8 KiB
Python
"""Тесты для 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
|