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