"""Golden-parity: `scraper_kit.base.save_listings` ≡ `app.services.scrapers.base.save_listings`. Strangler-инвариант (#2132): новая scraper_kit-копия базового слоя должна давать БАЙТ-ИДЕНТИЧНЫЙ эффект старому боевому коду на одинаковом входе. Единственное намеренное расхождение — развязка `region_code`: старый хардкодит литерал `66` в INSERT SQL, новый получает его параметром `:region_code` (kit не знает про регион). При region_code=66 итоговый INSERT идентичен — что этот тест и доказывает, нормализуя `:region_code` → `66` на новой стороне перед сравнением. Метод: гоняем ОБА модуля на одном списке `ScrapedLot`, перехватывая через recording-mock всю последовательность `db.execute(sql, params)` + аргументы hook'а матчинга. Сравниваем: - последовательность (SQL, params) вызовов execute (INSERT / SELECT / reconcile UPDATE / house_id_fk UPDATE) — после нормализации region_code; - kwargs каждого вызова matcher.match_or_create_house / upsert_listing_source; - compute_dedup_hash / compute_card_hash / compute_price_per_m2 старого и нового класса ScrapedLot на идентичном входе. Фикстуры покрывают: avito (без адреса), cian (адрес+coords → house resolve → FK UPDATE), студию rooms=0, yandex без source_id (ext_id := dedup_hash fallback) и dedup-коллизию (INSERT → UniqueViolation → reconcile UPDATE by source,source_id). """ from __future__ import annotations import contextlib from typing import Any from unittest.mock import MagicMock, patch import psycopg.errors from scraper_kit.base import ScrapedLot as NewLot from scraper_kit.base import save_listings as new_save_listings from sqlalchemy.exc import IntegrityError # СТАРЫЙ (боевой) и НОВЫЙ (scraper_kit) модули — импортируем оба в одном тесте. from app.services.scrapers.base import ScrapedLot as OldLot from app.services.scrapers.base import save_listings as old_save_listings _REGION_CODE = 66 # ── recording session ──────────────────────────────────────────────────────── class _Row: def __init__(self, **kw: Any) -> None: self.__dict__.update(kw) class _Result: def __init__(self, row: _Row | None) -> None: self._row = row def fetchone(self) -> _Row | None: return self._row @contextlib.contextmanager def _nested_cm() -> Any: yield MagicMock() class RecordingSession: """Мок Session: пишет (sql, params) всех execute, детерминированно отвечает. - SELECT card_hash … WHERE dedup_hash → None (prior_row отсутствует, fresh) - INSERT INTO listings … → row(id, inserted); если source_id в drift_ids → raise IntegrityError(UniqueViolation) (dedup-коллизия) - reconcile UPDATE listings SET dedup_hash … → row(id=reconcile_id) - UPDATE listings … house_id_fk / snapshot EXISTS → None """ def __init__( self, *, drift_ids: frozenset[str] = frozenset(), update_ids: frozenset[str] = frozenset(), insert_id: int = 42, reconcile_id: int = 99, ) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] self._drift = drift_ids self._update = update_ids self._insert_id = insert_id self._reconcile_id = reconcile_id def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result: s = str(sql) p = dict(params) if params else {} self.calls.append((s, p)) if "INSERT INTO listings (" in s: sid = p.get("source_id") if sid in self._drift: raise IntegrityError("INSERT", p, psycopg.errors.UniqueViolation("dup")) inserted = sid not in self._update return _Result(_Row(id=self._insert_id, inserted=inserted)) if "UPDATE listings" in s and "SET dedup_hash" in s: return _Result(_Row(id=self._reconcile_id)) return _Result(None) def begin_nested(self) -> Any: return _nested_cm() def commit(self) -> None: pass # ── fixtures ───────────────────────────────────────────────────────────────── def _fixture_data() -> list[dict[str, Any]]: """Реалистичные входные dict'ы (общие для old/new ScrapedLot).""" return [ # 1. avito без адреса → house resolve пропускается (нет FK UPDATE) { "source": "avito", "source_url": "https://www.avito.ru/ekaterinburg/kvartiry/1-k_987654?context=AB", "source_id": "987654", "price_rub": 4_500_000, "area_m2": 38.0, "rooms": 1, "floor": 5, "total_floors": 9, }, # 2. cian full: адрес + coords → house resolve → house_id_fk UPDATE { "source": "cian", "source_url": "https://ekb.cian.ru/sale/flat/123456/", "source_id": "123456", "price_rub": 6_200_000, "address": "Екатеринбург, улица Постовского, 17А", "lat": 56.8385, "lon": 60.5940, "rooms": 2, "area_m2": 52.0, "floor": 7, "total_floors": 16, "living_area_m2": 35.0, "bedrooms_count": 2, "cadastral_number": "66:41:0204016:1234", "building_cadastral_number": "66:41:0204016:100", "phones": [{"countryCode": "7", "number": "9012345678"}], "is_homeowner": True, "metro_stations": [{"name": "Геологическая", "time": 10}], "house_source": "cian", "listing_segment": "vtorichka", }, # 3. студия rooms=0 (avito) с адресом → house resolve { "source": "avito", "source_url": "https://www.avito.ru/ekaterinburg/kvartiry/studiya_555", "source_id": "555", "price_rub": 3_100_000, "address": "Екатеринбург, Малышева 51", "lat": 56.84, "lon": 60.60, "rooms": 0, "area_m2": 24.5, "floor": 12, "total_floors": 25, }, # 4. yandex без source_id → ext_id := dedup_hash fallback { "source": "yandex", "source_url": "https://realty.yandex.ru/offer/5500000000000000001/?from=serp", "source_id": None, "price_rub": 7_200_000, "address": "Екатеринбург, улица Мира, 10", "lat": 56.83, "lon": 60.65, "rooms": 2, "area_m2": 48.0, "yandex_offer_id": "5500000000000000001", "predicted_price_rub": 7_554_000, "price_trend": "DECREASED", "price_previous_rub": 7_500_000, }, # 5. dedup-коллизия: INSERT → UniqueViolation → reconcile UPDATE { "source": "cian", "source_url": "https://ekb.cian.ru/sale/flat/DRIFT-1/", "source_id": "DRIFT-1", "price_rub": 5_900_000, "address": "Екатеринбург, Ленина 1", "lat": 56.838, "lon": 60.597, "rooms": 3, "area_m2": 72.0, }, ] _DRIFT_IDS = frozenset({"DRIFT-1"}) # ── run helpers ────────────────────────────────────────────────────────────── def _run_old(lots: list[Any]) -> tuple[RecordingSession, MagicMock, MagicMock]: db = RecordingSession(drift_ids=_DRIFT_IDS) with ( patch("app.services.scrapers.base.match_or_create_house") as m_house, patch("app.services.scrapers.base.upsert_listing_source") as m_link, patch("app.services.scrapers.base.upsert_listing_snapshot"), ): m_house.return_value = (101, 1.0, "new") m_link.return_value = None old_save_listings(db, lots) return db, m_house, m_link def _run_new(lots: list[Any]) -> tuple[RecordingSession, MagicMock]: db = RecordingSession(drift_ids=_DRIFT_IDS) matcher = MagicMock() matcher.match_or_create_house.return_value = (101, 1.0, "new") matcher.upsert_listing_source.return_value = None with patch("scraper_kit.base.upsert_listing_snapshot"): new_save_listings(db, lots, matcher=matcher, region_code=_REGION_CODE) return db, matcher def _normalize_new_calls( calls: list[tuple[str, dict[str, Any]]], ) -> list[tuple[str, dict[str, Any]]]: """Нормализуем расхождение region_code: `:region_code` → `66`, убираем ключ. Это единственное намеренное отличие новой копии; после нормализации при region_code=66 SQL и params должны быть байт-идентичны старому боевому коду. """ out: list[tuple[str, dict[str, Any]]] = [] for sql, params in calls: norm_sql = sql.replace(":region_code", str(_REGION_CODE)) norm_params = {k: v for k, v in params.items() if k != "region_code"} out.append((norm_sql, norm_params)) return out def _kwargs_list(mock_or_method: Any) -> list[dict[str, Any]]: return [c.kwargs for c in mock_or_method.call_args_list] # ── tests ──────────────────────────────────────────────────────────────────── def test_save_listings_execute_sequence_parity() -> None: """Последовательность (SQL, params) execute идентична (после region_code-нормализации).""" lots = [NewLot(**d) for d in _fixture_data()] old_db, _oh, _ol = _run_old(lots) new_db, _nm = _run_new(lots) old_calls = old_db.calls new_calls = _normalize_new_calls(new_db.calls) assert len(new_calls) == len( old_calls ), f"разное число execute: old={len(old_calls)} new={len(new_calls)}" for i, (old_c, new_c) in enumerate(zip(old_calls, new_calls, strict=True)): assert old_c[0] == new_c[0], f"SQL расходится на вызове #{i}" assert old_c[1] == new_c[1], f"params расходятся на вызове #{i}" def test_insert_sql_region_code_injection() -> None: """Старый хардкодит `66` в VALUES; новый — параметр `:region_code`, params[region_code]=66.""" lots = [NewLot(**_fixture_data()[0])] old_db, _oh, _ol = _run_old(lots) new_db, _nm = _run_new(lots) old_insert = next(s for s, _ in old_db.calls if "INSERT INTO listings (" in s) new_insert, new_params = next((s, p) for s, p in new_db.calls if "INSERT INTO listings (" in s) assert ", 66," in old_insert and ":region_code" not in old_insert assert ":region_code," in new_insert and ", 66," not in new_insert assert new_params["region_code"] == 66 # После нормализации → идентичны assert new_insert.replace(":region_code", "66") == old_insert def test_matching_hook_kwargs_parity() -> None: """kwargs match_or_create_house / upsert_listing_source идентичны old ↔ new.""" lots = [NewLot(**d) for d in _fixture_data()] _old_db, m_house, m_link = _run_old(lots) _new_db, matcher = _run_new(lots) assert _kwargs_list(m_house) == _kwargs_list(matcher.match_or_create_house) assert _kwargs_list(m_link) == _kwargs_list(matcher.upsert_listing_source) # sanity: hook действительно сработал (не пустой прогон) assert m_link.call_count == len(lots) # house resolve только когда есть address/coords (4 из 5 фикстур) assert m_house.call_count == 4 assert matcher.match_or_create_house.call_count == 4 def test_dedup_collision_triggers_reconcile_update_in_both() -> None: """Drift-лот → INSERT UniqueViolation → reconcile UPDATE by (source, source_id) в обоих.""" lots = [NewLot(**_fixture_data()[4])] # DRIFT-1 old_db, _oh, _ol = _run_old(lots) new_db, _nm = _run_new(lots) old_reconcile = [s for s, _ in old_db.calls if "UPDATE listings" in s and "SET dedup_hash" in s] new_reconcile = [s for s, _ in new_db.calls if "UPDATE listings" in s and "SET dedup_hash" in s] assert len(old_reconcile) == 1 assert len(new_reconcile) == 1 assert old_reconcile[0] == new_reconcile[0] def test_return_counts_parity() -> None: """(inserted, updated) идентичны old ↔ new на одном входе.""" lots = [NewLot(**d) for d in _fixture_data()] old_db = RecordingSession(drift_ids=_DRIFT_IDS) with ( patch("app.services.scrapers.base.match_or_create_house", return_value=(101, 1.0, "new")), patch("app.services.scrapers.base.upsert_listing_source", return_value=None), patch("app.services.scrapers.base.upsert_listing_snapshot"), ): old_result = old_save_listings(old_db, lots) new_db = RecordingSession(drift_ids=_DRIFT_IDS) matcher = MagicMock() matcher.match_or_create_house.return_value = (101, 1.0, "new") matcher.upsert_listing_source.return_value = None with patch("scraper_kit.base.upsert_listing_snapshot"): new_result = new_save_listings(new_db, lots, matcher=matcher, region_code=_REGION_CODE) assert old_result == new_result def test_hash_parity_old_vs_new_scraped_lot() -> None: """compute_dedup_hash / compute_card_hash / compute_price_per_m2 идентичны у обоих классов.""" for data in _fixture_data(): old_lot = OldLot(**data) new_lot = NewLot(**data) assert old_lot.compute_dedup_hash() == new_lot.compute_dedup_hash() assert old_lot.compute_card_hash() == new_lot.compute_card_hash() assert old_lot.compute_price_per_m2() == new_lot.compute_price_per_m2() def test_kit_base_public_api_importable() -> None: """Acceptance: from scraper_kit.base import ScrapedLot, save_listings, BaseScraper.""" from scraper_kit.base import BaseScraper, ScrapedLot, save_listings assert ScrapedLot is not None assert callable(save_listings) assert BaseScraper is not None def test_kit_base_has_no_app_imports() -> None: """scraper_kit.base не должен импортировать app.* (strangler-развязка).""" import inspect import scraper_kit.base as kit_base source = inspect.getsource(kit_base) # Игнорируем docstring-упоминания: проверяем именно import-стейтменты. for line in source.splitlines(): stripped = line.strip() assert not stripped.startswith("from app"), f"app import найден: {line!r}" assert not stripped.startswith("import app"), f"app import найден: {line!r}"