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