gendesign/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

273 lines
16 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.

"""Разбор мёртвого кода #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 снова читается как рабочий флаг"