"""Разбор мёртвого кода #2674: подключить / удалить / задокументировать. Каждая правка эпика — тест, который краснеет без неё: подключено: - domrf_kapremont_load зарегистрирован Handler'ом И засеян в scrape_schedules — именно отсутствие этой пары держало загрузчик ДОМ.РФ невызванным; - filters_hash читается с estimation.sale.filtersHash, а не .data.filtersHash. удалено (гейт против возврата): - _phys_dedup_key / _extract_street_token — обёртки без прод-вызовов; - asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds — таблицы без читателя и писателя; - listings.merged_into, house_sources.raw_payload — колонки без писателя; - v_data_quality.price_disagreements_count — показатель, который не мог быть ненулевым; - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке. Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя; подключённый писатель за 7 дней записал 12 значений, все false. Разница между «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке. задокументировано: - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — сервис обязан сказать об этом вслух на старте. Без БД и сети: сессия замокана, SQL-миграции читаются как текст. """ from __future__ import annotations import os import re from pathlib import Path from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.providers.cian.valuation import _parse_valuation_state from app.services import estimator REPO_ROOT = Path(__file__).resolve().parents[3] TRADEIN = REPO_ROOT / "tradein-mvp" SQL_DIR = TRADEIN / "backend" / "data" / "sql" MIGRATION = SQL_DIR / "216_dead_code_sweep.sql" # ───────────────────────────────────────────────────────────────────────────── # Подключено 1/2: загрузчик ДОМ.РФ — оборванная проводка # ───────────────────────────────────────────────────────────────────────────── def test_domrf_loader_has_a_scheduler_handler() -> None: """Без Handler'а загрузчик ДОМ.РФ был невызываем — этого и не хватало.""" from app.services.product_handlers import build_product_handlers handlers = build_product_handlers(MagicMock()) assert "domrf_kapremont_load" in handlers def test_domrf_loader_is_seeded_into_schedules() -> None: """Handler без строки расписания так же нем, как расписание без Handler'а.""" sql = MIGRATION.read_text(encoding="utf-8") assert "'domrf_kapremont_load'" in sql assert "INSERT INTO scrape_schedules" in sql # Недельный такт: реестр капремонта не меняется ежедневно, а прогон качает два zip. assert '"interval_days": 7' in sql def test_domrf_window_does_not_collide_with_matview_refresh() -> None: """Окно ДОМ.РФ не должно совпадать с refresh_search_matview (03:00-04:00 UTC). Ревью #2689: планировщик берёт случайный момент внутри окна и гоняет источники параллельно — общее окно с тем заданием, которое переносит year_built в поиск, это подбрасывание монеты. Тест ловит откат окна обратно на 3. """ sql = MIGRATION.read_text(encoding="utf-8") values = sql.split("'domrf_kapremont_load',", 1)[1].split(")", 1)[0] tokens = [t.strip().rstrip(",") for t in values.splitlines()] hours = [int(t) for t in tokens if t.isdigit()] assert hours, "не нашли window_start_hour/window_end_hour в INSERT" start, end = hours[0], hours[1] matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06 assert end <= matview_start or start >= matview_end, ( f"окно {start}-{end} пересекается с refresh_search_matview {matview_start}-{matview_end}" ) def test_domrf_handler_reuses_loader_functions() -> None: """Дизайн-инвариант product_handlers: job переиспользует боевое тело, не копирует.""" src = (TRADEIN / "backend" / "app" / "services" / "product_handlers.py").read_text( encoding="utf-8" ) body = src.split("_job_domrf_kapremont_load", 1)[1].split("# ──", 1)[0] for fn in ( "load_domrf_kapremont", "backfill_houses_from_domrf", "propagate_listings_year_from_houses", ): assert fn in body, f"{fn} не вызывается — загрузчик подключён лишь наполовину" # ───────────────────────────────────────────────────────────────────────────── # Подключено 2/2: filters_hash лежит на уровень выше, чем его читали # ───────────────────────────────────────────────────────────────────────────── def test_filters_hash_read_from_sale_wrapper() -> None: """Прод-форма ответа: ключи estimation.sale = {isError, filtersHash, data, isFetching}.""" state = { "user": {"isAuthenticated": True, "userId": 1}, "estimation": { "sale": { "isError": False, "isFetching": False, "filtersHash": "96bba2876162e2b822f80eec", "data": {"price": 9_000_000, "accuracy": 12}, }, "rent": {"data": {}}, }, } assert _parse_valuation_state(state).filters_hash == "96bba2876162e2b822f80eec" def test_filters_hash_absent_stays_none() -> None: """Нет ключа → None. Со старым (вложенным) путём тест бы прошёл — он не про фикс.""" state = {"estimation": {"sale": {"data": {"price": 1}}, "rent": {"data": {}}}} assert _parse_valuation_state(state).filters_hash is None def test_filters_hash_backfill_uses_the_same_path() -> None: """Миграция достаёт хеш ровно оттуда же, откуда его теперь читает парсер.""" sql = MIGRATION.read_text(encoding="utf-8") assert "'{estimation,sale,filtersHash}'" in sql assert "WHERE filters_hash IS NULL" in sql # ───────────────────────────────────────────────────────────────────────────── # Удалено: гейты против возврата # ───────────────────────────────────────────────────────────────────────────── def _live_python_sources() -> list[Path]: """Боевой Python trade-in: app + scraper-kit + browser. Без тестов.""" roots = [ TRADEIN / "backend" / "app", TRADEIN / "packages" / "scraper-kit" / "src", TRADEIN / "browser", ] return [p for root in roots for p in root.rglob("*.py") if not p.name.startswith("test_")] def test_dead_dedup_wrappers_are_gone() -> None: """Обёртки без прод-вызовов (25 ссылок, все из тестов) не должны вернуться.""" for name in ("_phys_dedup_key", "_extract_street_token"): assert not hasattr(estimator, name), ( f"{name} снова в estimator — эта обёртка описывала правило, " "которого в боевом дедупе (_union_find_phys_dedup) нет" ) def test_dead_names_absent_from_live_code() -> None: """Имена удалённых таблиц/колонок/показателей не упоминаются в боевом коде. SQL-миграции сознательно НЕ проверяем: 098/028/029/046 — исторические файлы, переписывать их задним числом нельзя (пересборка с нуля идёт по ним). """ dead = [ "asking_to_sold_ratios_tiered", "asking_to_sold_tier_bounds", "price_disagreements_count", # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета # источников. Гейт краснеет на любом из трёх, а не только на одном (первая # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет). "has_panorama", ] offenders: list[str] = [] for path in _live_python_sources(): text = path.read_text(encoding="utf-8") for name in dead: if name in text: offenders.append(f"{path.relative_to(REPO_ROOT)}: {name}") assert not offenders, "удалённое снова упоминается: " + "; ".join(offenders) def test_dropped_columns_have_no_python_writer() -> None: """merged_into / house_sources.raw_payload: писателя не было и быть не должно.""" offenders = [ str(p.relative_to(REPO_ROOT)) for p in _live_python_sources() if "merged_into" in p.read_text(encoding="utf-8") ] assert not offenders, f"listings.merged_into снова упомянут: {offenders}" hs_writers = [ p for p in _live_python_sources() if "INSERT INTO house_sources" in p.read_text(encoding="utf-8") ] assert hs_writers, "писатели house_sources исчезли — тест потерял смысл, проверь grep" for path in hs_writers: stmt = path.read_text(encoding="utf-8").split("INSERT INTO house_sources", 1)[1] stmt = stmt.split("VALUES", 1)[0] assert "raw_payload" not in stmt, f"{path} снова пишет удалённую колонку" def test_migration_drops_exactly_what_was_declared_dead() -> None: sql = MIGRATION.read_text(encoding="utf-8") for stmt in ( "DROP TABLE IF EXISTS asking_to_sold_ratios_tiered", "DROP TABLE IF EXISTS asking_to_sold_tier_bounds", "ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS merged_into", "ALTER TABLE IF EXISTS house_sources DROP COLUMN IF EXISTS raw_payload", "DROP INDEX IF EXISTS house_sources_raw_payload_gin_idx", ): assert stmt in sql, f"миграция не выполняет: {stmt}" # Показатель убран из KPI-снимка, но сам view-источник оставлен как задел. view_ddl = sql.split("CREATE OR REPLACE VIEW v_data_quality", 1)[1].split(";", 1)[0] assert "price_disagreements_count" not in view_ddl assert "COMMENT ON VIEW v_price_divergence" in sql def test_has_panorama_dropped_together_with_the_contract_column() -> None: """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант. has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен. """ sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8") assert "DROP VIEW IF EXISTS market.v_houses" in sql assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока" view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0] assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой" # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей. for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"): assert kept in view_ddl, f"{kept} пропала из контракта при пересборке" assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, ( "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит " "permission denied на следующем прогоне" ) def test_price_divergence_is_documented_as_structurally_empty() -> None: """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" sql = MIGRATION.read_text(encoding="utf-8") comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0] assert "match_or_create_listing" in comment, ( "комментарий не называет причину пустоты — без неё это просто «пока пусто»" ) # ───────────────────────────────────────────────────────────────────────────── # Задокументировано: мёртвая переменная окружения # ───────────────────────────────────────────────────────────────────────────── def test_browser_warns_about_retired_env() -> None: """BROWSER_BLOCK_RESOURCES выставлен в трёх прод-контейнерах и ни на что не влияет. Сам browser/server.py тянет aiohttp+camoufox и в backend-окружении не импортируется, поэтому проверяем исходник: переменная обязана быть в реестре отставных И должна логироваться предупреждением на старте. """ src = (TRADEIN / "browser" / "server.py").read_text(encoding="utf-8") assert "_RETIRED_ENV" in src assert '"BROWSER_BLOCK_RESOURCES"' in src assert "_warn_retired_env()" in src, "предупреждение не вызывается со старта" # Переменная НЕ должна снова начать что-то менять — только предупреждать. read_sites = re.findall(r'os\.environ(?:\.get)?[\[(]"BROWSER_BLOCK_RESOURCES"', src) assert not read_sites, "BROWSER_BLOCK_RESOURCES снова читается как рабочий флаг"