gendesign/tradein-mvp/backend/tests/test_cian_bti_house_persist.py
lekss361 7c1d3693e2
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / deploy (push) Successful in 6m36s
fix(tradein/cian): персист bti_data/valuation house_info в houses + дедуп УК (#2435) (#2437)
2026-07-04 20:21:32 +00:00

412 lines
15 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.

"""Тесты для Cian BTI / Valuation house-persist (#2435).
`bti_data` (cian/detail.py) и valuation `house_info`/`managementCompany`
(cian/valuation.py) парсились, но раньше выбрасывались («это задача Stage 6
(houses), где есть house_id») — теперь пишутся в канонический houses-ряд:
- detail.py: резолвит дом через инжектируемый `matcher: HouseMatcher`
(`match_or_create_house`, address/geo листинга) — mirror avito's `_persist_house`.
- valuation.py: `house_id` уже резолвлен ВЫЗЫВАЮЩИМ (estimator.py, read-only
`match_house_readonly`) — здесь только COALESCE-UPDATE, без создания дома.
MagicMock db — без реальной БД, тот же паттерн, что test_snapshot_writer.py /
test_extval_house_id_write_path.py.
"""
from __future__ import annotations
import os
# Settings требует DATABASE_URL на import (см. test_extval_house_id_write_path.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from contextlib import contextmanager
from unittest.mock import MagicMock
from scraper_kit.providers.cian.detail import DetailEnrichment, save_detail_enrichment
from scraper_kit.providers.cian.valuation import CianValuationResult, _save_to_cache
# ── Part 1: bti_data → houses (detail.py) ────────────────────────────────────
_BTI_SAMPLE = {
"yearRelease": 1961,
"houseMaterialType": "brick",
"floorMax": 4,
"entrances": 4,
"flatCount": 58,
"isEmergency": False,
"houseHeatSupplyType": "central",
"houseGasSupplyType": "central",
"houseOverlapType": "concrete",
"lifts": 0,
"seriesName": "1-447",
}
def _mock_matcher(house_id: int | None = 501, method: str = "new") -> MagicMock:
matcher = MagicMock()
matcher.match_or_create_house.return_value = (house_id, 1.0, method)
return matcher
def _mock_db_bti(
address: str | None = "Екатеринбург, улица Малышева, 51",
lat: float = 56.83,
lon: float = 60.6,
) -> MagicMock:
"""Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict."""
db = MagicMock()
@contextmanager
def _nested():
yield MagicMock()
db.begin_nested.side_effect = _nested
def _exec(sql, params=None):
sql_str = str(sql)
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_result.scalar_one_or_none.return_value = None
if "SELECT address, lat, lon FROM listings" in sql_str:
mock_result.mappings.return_value.first.return_value = {
"address": address,
"lat": lat,
"lon": lon,
}
else:
mock_result.mappings.return_value.first.return_value = None
return mock_result
db.execute.side_effect = _exec
return db
def _bti_update_call(db: MagicMock) -> tuple[str, dict] | None:
for call_args in db.execute.call_args_list:
if not call_args.args:
continue
sql = str(call_args.args[0])
if "UPDATE houses" in sql and "series_name" in sql:
params = call_args.args[1] if len(call_args.args) >= 2 else {}
return sql, params
return None
def test_bti_data_present_updates_house_columns_via_coalesce():
"""bti_data + matcher → UPDATE houses SET series_name=COALESCE(...) с BTI-полями."""
db = _mock_db_bti()
matcher = _mock_matcher(house_id=501)
enrichment = DetailEnrichment(bti_data=dict(_BTI_SAMPLE))
save_detail_enrichment(db, 123, enrichment, matcher=matcher)
matcher.match_or_create_house.assert_called_once()
kwargs = matcher.match_or_create_house.call_args.kwargs
assert kwargs["ext_source"] == "cian_bti"
assert kwargs["ext_id"] == "123"
assert kwargs["address"] == "Екатеринбург, улица Малышева, 51"
call = _bti_update_call(db)
assert call is not None, "UPDATE houses с BTI-полями не найден"
sql, params = call
assert "COALESCE" in sql
assert params["house_id"] == 501
assert params["series_name"] == "1-447"
assert params["entrances"] == 4
assert params["flat_count"] == 58
assert params["is_emergency"] is False
assert params["heat_supply_type"] == "central"
assert params["gas_supply_type"] == "central"
assert params["overlap_type"] == "concrete"
db.commit.assert_called_once()
def test_bti_data_absent_is_noop_no_crash():
"""bti_data=None (default) → matcher не вызывается, UPDATE houses не выполняется."""
db = _mock_db_bti()
matcher = _mock_matcher()
enrichment = DetailEnrichment() # bti_data=None
save_detail_enrichment(db, 124, enrichment, matcher=matcher)
matcher.match_or_create_house.assert_not_called()
assert _bti_update_call(db) is None
db.commit.assert_called_once()
def test_bti_data_present_but_no_matcher_is_noop():
"""matcher=None (default, backward-compat для существующих вызовов) → bti_data игнорируется."""
db = _mock_db_bti()
enrichment = DetailEnrichment(bti_data=dict(_BTI_SAMPLE))
save_detail_enrichment(db, 125, enrichment) # no matcher kwarg
assert _bti_update_call(db) is None
db.commit.assert_called_once()
def test_bti_house_resolution_refused_no_crash():
"""matcher отказал (безномерный адрес, method='no_house_number') → graceful skip."""
db = _mock_db_bti()
matcher = _mock_matcher(house_id=None, method="no_house_number")
enrichment = DetailEnrichment(bti_data=dict(_BTI_SAMPLE))
save_detail_enrichment(db, 126, enrichment, matcher=matcher)
assert _bti_update_call(db) is None
db.commit.assert_called_once()
def test_bti_persist_exception_does_not_abort_main_save():
"""Исключение в BTI house-persist (напр. UPDATE houses упал) не прерывает основной save."""
db = _mock_db_bti()
matcher = _mock_matcher(house_id=501)
enrichment = DetailEnrichment(bti_data=dict(_BTI_SAMPLE))
original_side_effect = db.execute.side_effect
def _boom(sql, params=None):
if "UPDATE houses" in str(sql):
raise RuntimeError("boom")
return original_side_effect(sql, params)
db.execute.side_effect = _boom
# Не должно бросить исключение наружу.
save_detail_enrichment(db, 127, enrichment, matcher=matcher)
db.commit.assert_called_once()
def test_bti_missing_listing_address_skips_resolve():
"""Листинг без адреса → matcher не вызывается, no crash (best-effort)."""
db = _mock_db_bti(address="")
matcher = _mock_matcher()
enrichment = DetailEnrichment(bti_data=dict(_BTI_SAMPLE))
save_detail_enrichment(db, 128, enrichment, matcher=matcher)
matcher.match_or_create_house.assert_not_called()
db.commit.assert_called_once()
# ── Part 2: valuation house_info/managementCompany → houses (valuation.py) ──
_HOUSE_INFO_SAMPLE = [
{"title": "Год постройки", "value": 2005},
{"title": "Тип дома", "value": "Кирпичный"},
{"title": "Этажность", "value": 16},
{"title": "Газоснабжение", "value": "Отсутствует"},
{"title": "Отопление", "value": "Центральное"},
{"title": "Тип перекрытий", "value": "Железобетонные"},
{"title": "Подъездов", "value": 1},
{"title": "Количество лифтов", "value": "1 пассажирский, 1 грузовой"},
{"title": "Мусоропровод", "value": "Нет"},
{"title": "Квартир", "value": 105},
{"title": "Реновация", "value": "Нет"},
{"title": "Аварийность", "value": "Нет"},
{"title": "Детская площадка", "value": "Нет"},
{"title": "Спортивная площадка", "value": "Нет"},
]
def _cian_result(
house_info: list[dict] | None = None,
management_company: dict | None = None,
external_house_id: int | None = 54016,
) -> CianValuationResult:
return CianValuationResult(
sale_price_rub=10_000_000,
sale_accuracy=85.0,
chart=[],
external_house_id=external_house_id,
house_info=house_info or [],
management_company=management_company,
)
def _mock_db_valuation(
mc_insert_id: int | None = 900, mc_existing_id: int | None = None
) -> MagicMock:
"""Mock db для _save_to_cache: SELECT (dedup) → UPDATE-or-INSERT management_companies,
затем UPDATE houses.
`mc_existing_id` simulates the dedup SELECT finding a prior row (existing.fetchone()
returns that id) — the UPDATE branch is taken instead of INSERT. Default None means
"no existing row", exercising the INSERT branch (the original test-suite behavior).
"""
db = MagicMock()
@contextmanager
def _nested():
yield MagicMock()
db.begin_nested.side_effect = _nested
def _exec(sql, params=None):
sql_str = str(sql)
mock_result = MagicMock()
if "SELECT id FROM management_companies" in sql_str:
mock_result.fetchone.return_value = (
(mc_existing_id,) if mc_existing_id is not None else None
)
elif "INSERT INTO management_companies" in sql_str:
mock_result.fetchone.return_value = (
(mc_insert_id,) if mc_insert_id is not None else None
)
else:
mock_result.fetchone.return_value = None
return mock_result
db.execute.side_effect = _exec
return db
def _mc_calls(db: MagicMock) -> list[str]:
"""SQL statement kinds touching management_companies, in call order."""
kinds = []
for call_args in db.execute.call_args_list:
if not call_args.args:
continue
sql = str(call_args.args[0])
if "SELECT id FROM management_companies" in sql:
kinds.append("select")
elif "INSERT INTO management_companies" in sql:
kinds.append("insert")
elif "UPDATE management_companies" in sql:
kinds.append("update")
return kinds
def _houses_update_call(db: MagicMock) -> tuple[str, dict] | None:
for call_args in db.execute.call_args_list:
if not call_args.args:
continue
sql = str(call_args.args[0])
if "UPDATE houses" in sql:
params = call_args.args[1] if len(call_args.args) >= 2 else {}
return sql, params
return None
def _call_save_to_cache(db: MagicMock, result: CianValuationResult, house_id: int | None) -> None:
_save_to_cache(
db,
cache_key="ck",
address="Екатеринбург, улица Учителей, 18",
total_area=50.0,
rooms_count=2,
floor=3,
total_floors=16,
repair_type="cosmetic",
deal_type="sale",
result=result,
house_id=house_id,
)
def test_valuation_house_info_and_management_company_land_in_houses_update():
"""house_id задан, УК ранее не встречалась → SELECT (не найдено) → INSERT management_companies,
UPDATE houses с management_company_id + house_info-полями."""
db = _mock_db_valuation(mc_insert_id=900)
result = _cian_result(
house_info=_HOUSE_INFO_SAMPLE,
management_company={
"name": 'ТСЖ "Учителей, 18"',
"phones": ["+7 (343) 216-59-12"],
"email": "tsg.u.18@mail.ru",
"openingHours": [{"Пн-Пт": ["10:00-20:00"]}],
"chiefName": "Сабиров Наиль Вагизович",
},
)
_call_save_to_cache(db, result, house_id=42)
assert _mc_calls(db) == ["select", "insert"]
call = _houses_update_call(db)
assert call is not None
sql, params = call
assert "COALESCE" in sql
assert params["hid"] == 42
assert params["mcid"] == 900
assert params["cihi"] == 54016
assert params["yb"] == 2005
assert params["ht"] == "Кирпичный"
assert params["tf"] == 16
assert params["gst"] == "Отсутствует"
assert params["hst"] == "Центральное"
assert params["ot"] == "Железобетонные"
assert params["ent"] == 1
assert params["fc"] == 105
assert params["ie"] is False
assert params["hp"] is False
assert params["pe"] == 1
assert params["ce"] == 1
db.commit.assert_called_once()
def test_valuation_management_company_dedups_on_repeat_call_same_name():
"""Regression for the NULLS-DISTINCT dedup bug (#2435 review): a repeat valuation
call for a management company already persisted (same ext_source/name, ext_id=NULL)
must find it via the explicit SELECT and UPDATE it — not INSERT a duplicate row.
`ON CONFLICT (ext_source, name, ext_id)` alone can't dedup ext_id=NULL rows under
Postgres's default NULLS DISTINCT (two NULLs never compare equal), which is why
dedup is done via SELECT-before-INSERT rather than relying on the constraint."""
db = _mock_db_valuation(mc_existing_id=900)
result = _cian_result(
house_info=[],
management_company={"name": 'ТСЖ "Учителей, 18"', "phones": ["+7 (343) 216-59-12"]},
)
_call_save_to_cache(db, result, house_id=42)
assert _mc_calls(db) == ["select", "update"]
call = _houses_update_call(db)
assert call is not None
_, params = call
assert params["mcid"] == 900
db.commit.assert_called_once()
def test_valuation_house_id_none_skips_house_update():
"""house_id=None → нет UPDATE houses (best-effort no-op, тот же no-crash контракт,
что test_extval_house_id_write_path.py::test_cian_save_null_house_id_no_exception)."""
db = _mock_db_valuation()
result = _cian_result(house_info=_HOUSE_INFO_SAMPLE, management_company={"name": "УК Тест"})
_call_save_to_cache(db, result, house_id=None)
assert _houses_update_call(db) is None
db.commit.assert_called_once()
def test_valuation_house_persist_exception_does_not_abort_main_save():
"""UPDATE houses упал → перехвачено, external_valuations INSERT + commit всё равно проходят."""
db = _mock_db_valuation()
result = _cian_result(house_info=[], management_company=None)
def _boom(sql, params=None):
if "UPDATE houses" in str(sql):
raise RuntimeError("boom")
mock_result = MagicMock()
mock_result.fetchone.return_value = None
return mock_result
db.execute.side_effect = _boom
_call_save_to_cache(db, result, house_id=42)
db.commit.assert_called_once()
def test_valuation_no_management_company_leaves_mcid_none():
"""management_company=None → mc_id=None → management_company_id COALESCE(None, existing)."""
db = _mock_db_valuation()
result = _cian_result(house_info=[], management_company=None)
_call_save_to_cache(db, result, house_id=42)
call = _houses_update_call(db)
assert call is not None
_sql, params = call
assert params["mcid"] is None