"""#1871 P2 — radius-tier (source, source_id) dedup в _fetch_analogs. Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один address и выживают на разных rn_addr рангах → раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет. Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard). Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном тексте (как test_estimator_price_trend_dedup.py). """ from __future__ import annotations import os from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import pytest import app.services.estimator as est def _capture_tier_sql() -> list[str]: """Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира. target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх тиров отрендерился (каждый тир возвращает [] → fallthrough к следующему). """ captured: list[str] = [] db = MagicMock() def side_effect(*args, **kwargs): # type: ignore[no-untyped-def] captured.append(str(args[0].text)) result = MagicMock() result.mappings.return_value.all.return_value = [] return result db.execute.side_effect = side_effect est._fetch_analogs( db, lat=56.83, lon=60.6, rooms=2, area=50.0, radius_m=2000, full_address="г Екатеринбург, ул Малышева, д 30", year_built=2010, house_type="монолит", total_floors=20, target_house_id=123, ) return captured # --------------------------------------------------------------------------- # SQL-фрагмент во всех тирах # --------------------------------------------------------------------------- def test_all_four_tiers_render() -> None: sqls = _capture_tier_sql() assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W" def test_rn_dup_window_present_in_every_tier() -> None: sqls = _capture_tier_sql() for i, sql in enumerate(sqls): assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна" assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра" def test_rn_dup_key_namespaced_and_freshest() -> None: """Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC.""" sql = _capture_tier_sql()[0] assert "PARTITION BY source" in sql assert "'id:' || source_id::text" in sql assert "'url:' || source_url" in sql assert "'ctid:' || ctid::text" in sql # freshest: окно сортируется по scraped_at DESC → rn_dup=1 это свежайшая строка win_start = sql.find("AS rn_dup") win_region = sql[max(0, win_start - 400) : win_start] assert "ORDER BY scraped_at DESC" in win_region # --------------------------------------------------------------------------- # psycopg3-инвариант: column::type OK, :bind::type запрещён # --------------------------------------------------------------------------- def test_no_bind_param_double_colon_cast() -> None: """В сгенерированном SQL нет :name::type (только column::type, что легально).""" import re bad = re.compile(r":[a-z_]+::[a-z]") for i, sql in enumerate(_capture_tier_sql()): assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type" # --------------------------------------------------------------------------- # Семантика дедупа окна (Python-симуляция row_number() OVER PARTITION/ORDER) # --------------------------------------------------------------------------- def _simulate_rn_dup(rows: list[dict]) -> list[dict]: """Эмулирует SQL-окно rn_dup=1: per (source, key) freshest scraped_at. Зеркалит SQL: ключ = source + namespaced(id:source_id | url:source_url | ctid:ctid). NULL source_id+source_url → разные ctid → разные группы (обе живут). """ groups: dict[tuple, dict] = {} for r in rows: if r.get("source_id") is not None: key = (r["source"], "id", r["source_id"]) elif r.get("source_url") is not None: key = (r["source"], "url", r["source_url"]) else: key = (r["source"], "ctid", r["ctid"]) cur = groups.get(key) if cur is None or r["scraped_at"] > cur["scraped_at"]: groups[key] = r return list(groups.values()) def test_same_source_source_id_collapses_to_freshest() -> None: rows = [ { "source": "yandex", "source_id": "A1", "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 150_000, }, { "source": "yandex", "source_id": "A1", "source_url": None, "ctid": "(0,2)", "scraped_at": 200, "price_per_m2": 151_000, }, ] out = _simulate_rn_dup(rows) assert len(out) == 1 assert out[0]["scraped_at"] == 200, "должна выжить freshest (scraped_at=200)" def test_different_source_id_both_survive() -> None: rows = [ { "source": "cian", "source_id": "X", "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 140_000, }, { "source": "cian", "source_id": "Y", "source_url": None, "ctid": "(0,2)", "scraped_at": 100, "price_per_m2": 145_000, }, ] out = _simulate_rn_dup(rows) assert len(out) == 2 def test_same_source_id_different_source_both_survive() -> None: """(source, source_id) — PARTITION включает source: одинаковый id у разных источников НЕ схлопывается.""" rows = [ { "source": "cian", "source_id": "Z", "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 140_000, }, { "source": "yandex", "source_id": "Z", "source_url": None, "ctid": "(0,2)", "scraped_at": 100, "price_per_m2": 145_000, }, ] out = _simulate_rn_dup(rows) assert len(out) == 2 def test_null_source_id_null_url_both_survive_via_ctid() -> None: """CASE-guard: 2 строки с NULL source_id И NULL source_url → ctid-tiebreak → обе сохранены (не схлопываются по NULL).""" rows = [ { "source": "rosreestr", "source_id": None, "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 130_000, }, { "source": "rosreestr", "source_id": None, "source_url": None, "ctid": "(0,2)", "scraped_at": 100, "price_per_m2": 132_000, }, ] out = _simulate_rn_dup(rows) assert len(out) == 2 def test_null_source_id_falls_back_to_source_url() -> None: """source_id NULL, но одинаковый source_url → схлопываются (url-ключ).""" rows = [ { "source": "avito", "source_id": None, "source_url": "http://a/1", "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 120_000, }, { "source": "avito", "source_id": None, "source_url": "http://a/1", "ctid": "(0,2)", "scraped_at": 300, "price_per_m2": 121_000, }, ] out = _simulate_rn_dup(rows) assert len(out) == 1 assert out[0]["scraped_at"] == 300 # --------------------------------------------------------------------------- # n_analogs отражает дедупнутое число (Python-симуляция) # --------------------------------------------------------------------------- def test_n_analogs_reflects_deduped_count() -> None: """3 строки одного объекта (source_id) схлопываются в 1 → n_analogs=1. Прямое требование спека: «n_analogs отражает дедупнутое число» — без дедупа 3 дубля раздули бы счётчик, с дедупом выживает 1 (freshest). Отдельные объекты (разные source_id) не схлопываются → n_analogs=3. """ # 3 дубля одного объекта (один source_id, разные scraped_at) dupes = [ { "source": "yandex", "source_id": "Y1", "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 150_000, }, { "source": "yandex", "source_id": "Y1", "source_url": None, "ctid": "(0,2)", "scraped_at": 200, "price_per_m2": 151_000, }, { "source": "yandex", "source_id": "Y1", "source_url": None, "ctid": "(0,3)", "scraped_at": 300, "price_per_m2": 152_000, }, ] out = _simulate_rn_dup(dupes) n_analogs = len(out) assert n_analogs == 1, "3 дубля → должен выжить 1" assert out[0]["scraped_at"] == 300, "выживает freshest" assert out[0]["price_per_m2"] == 152_000 # 3 разных объекта — не схлопываются distinct = [ { "source": "yandex", "source_id": "Y1", "source_url": None, "ctid": "(0,1)", "scraped_at": 100, "price_per_m2": 150_000, }, { "source": "cian", "source_id": "C1", "source_url": None, "ctid": "(0,2)", "scraped_at": 100, "price_per_m2": 145_000, }, { "source": "avito", "source_id": None, "source_url": "http://a/1", "ctid": "(0,3)", "scraped_at": 100, "price_per_m2": 155_000, }, ] out_d = _simulate_rn_dup(distinct) assert len(out_d) == 3, "3 разных объекта → все 3 живут" if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"]))