"""Static guards for migration 165 (issue #2204 — полное выключение источника n1). Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой БД; здесь фиксируем структурные инварианты, которые ГАРАНТИРУЮТ идемпотентность и НЕдеструктивность к самим listings-строкам по построению: 1. Транзакционность (BEGIN/COMMIT) — атомарный апдейт. 2. НЕдеструктивность к данным: строки listings source='n1' НЕ удаляются, только деактивируются (is_active=false) — исторические объявления остаются в БД. Никакого `DELETE FROM listings` в коде. 3. Идемпотентность по построению: - страховочный UPDATE ограничен `AND is_active` -> повторный прогон no-op; - `DELETE ... WHERE source IN (...)` / `WHERE source = 'n1'` -> повторный прогон удаляет 0 строк. 4. Скоуп чистки конфига: обе n1-строки расписаний (n1_city_sweep + deactivate_stale_n1) и per-source knob scraper_settings 'n1'. Комментарий-заголовок в прозе объясняет намерение (в т.ч. фразу «Никакого DELETE FROM listings»), поэтому destructive-проверки смотрят на ИСПОЛНЯЕМЫЙ SQL (`--`-комменты вырезаны), а не на прозу. """ from __future__ import annotations import re from pathlib import Path _SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" _MIGRATION_165 = _SQL_DIR / "165_remove_n1_source.sql" def _sql() -> str: return _MIGRATION_165.read_text(encoding="utf-8") def _executable_sql() -> str: """SQL без комментариев — только исполняемые строки (`--`-комменты вырезаны). В этой миграции `--` не встречается внутри строковых литералов, поэтому split по первому `--` безопасно отделяет код от построчного комментария. """ lines = [] for raw in _sql().splitlines(): code = raw.split("--", 1)[0] if code.strip(): lines.append(code) return "\n".join(lines) def _flat(text: str) -> str: return re.sub(r"\s+", " ", text).strip().lower() def test_migration_165_exists() -> None: assert _MIGRATION_165.exists(), f"missing migration: {_MIGRATION_165}" def test_migration_165_is_transactional() -> None: sql = _sql() assert "BEGIN;" in sql assert "COMMIT;" in sql def test_migration_165_does_not_delete_listings() -> None: """Строки listings source='n1' НЕ удаляются — только деактивируются.""" flat = _flat(_executable_sql()) assert "delete from listings" not in flat # Деактивация вместо удаления. assert "update listings" in flat assert "set is_active = false" in flat def test_migration_165_listings_update_idempotent_and_scoped() -> None: """UPDATE ограничен source='n1' AND is_active -> повторный прогон no-op.""" flat = _flat(_executable_sql()) assert "where source = 'n1'" in flat assert "and is_active" in flat def test_migration_165_deletes_both_n1_schedules() -> None: flat = _flat(_executable_sql()) assert "delete from scrape_schedules" in flat assert "'n1_city_sweep'" in flat assert "'deactivate_stale_n1'" in flat def test_migration_165_deletes_n1_scraper_settings() -> None: flat = _flat(_executable_sql()) assert "delete from scraper_settings" in flat assert "where source = 'n1'" in flat def test_migration_165_no_psycopg_trap() -> None: """Никаких :param::type — psycopg v3 требует CAST(... AS type).""" assert not re.search(r":\w+::", _sql())