gendesign/tradein-mvp/backend/tests/test_cian_bti_house_persist.py
bot-backend 37ea0b0f5b fix(tradein/cian): персистить bti_data и valuation house_info в houses (#2435)
Cian secondary-market detail (bti_data) и Valuation Calculator (house_info/
managementCompany/houseId) парсились, но выбрасывались — комментарий "это
задача Stage 6 (houses)" так и не был выполнен.

- cian/detail.py::save_detail_enrichment принимает инжектируемый matcher
  (HouseMatcher, optional) → новый _persist_cian_bti_house резолвит дом через
  match_or_create_house (address/geo листинга, mirror avito/houses.py::
  _persist_house) и пишет BTI-эксклюзивные колонки из 020_houses_alter_cian.sql
  (series_name/entrances/flat_count/is_emergency/heat_supply_type/
  gas_supply_type/overlap_type) через COALESCE(new, existing).
- cian/valuation.py::_save_to_cache получает уже резолвленный house_id (read-only
  match_house_readonly, estimator.py) → новый _persist_cian_valuation_house пишет
  management_company_id (UPSERT management_companies) + cian_internal_house_id
  (filters.houseId) COALESCE(new, existing), плюс houseInfo.items-производные
  поля (год/тип/этажность/газ/отопление/перекрытия/подъезды/квартиры/
  аварийность/детская площадка/лифты) COALESCE(existing, new) — валюация не
  авторитетный источник для них (conflict_resolution.HOUSE_FIELD_PRIORITY).
- Оба пути best-effort: SAVEPOINT (db.begin_nested) изолирует сбой резолва/
  записи дома от основной save-транзакции; house_id=None / matcher=None /
  безномерный адрес (P1 no_house_number) — graceful no-op, без исключений.
- matcher прокинут в реальные call sites: pipeline.py (cian city-sweep +
  full-load — уже был в scope), cian_history_backfill.py, cian_price_history.py,
  admin.py ad-hoc endpoint.

Gap (нет чистого маппинга на существующую колонку houses — не создавали новых
колонок): bti.houseData.{demolishedInMoscowProgramm, heatIndex,
houseOverhaulFundType, lifts (недифференцированный total)}; houseInfo.items
{Мусоропровод, Реновация, Спортивная площадка, Фонд капремонта}.

10 новых тестов (tests/test_cian_bti_house_persist.py), MagicMock db, без
реальной БД — mirror test_snapshot_writer.py / test_extval_house_id_write_path.py.
2026-07-04 22:56:36 +03:00

359 lines
13 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) -> MagicMock:
"""Mock db для _save_to_cache: INSERT management_companies RETURNING id + UPDATE houses."""
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 "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 _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 задан → 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)
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_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