95 lines
4.1 KiB
Python
95 lines
4.1 KiB
Python
"""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())
|