"""Invariants over data/sql migrations (issue #2216). Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый `data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename). Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии: 1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый filename считается неприменённым и прогоняется повторно (дубль-INSERT, повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже, молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать. 2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник двусмысленного порядка применения (`ls | sort` детерминирован, но человек легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в manifest). Любой НОВЫЙ файл обязан нести уникальный префикс. Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR (см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность префикса у новых файлов; сам manifest дополняет автор миграции. """ from __future__ import annotations import re from pathlib import Path _BACKEND_ROOT = Path(__file__).resolve().parents[1] _SQL_DIR = _BACKEND_ROOT / "data" / "sql" _MANIFEST = _SQL_DIR / "_manifest_applied.txt" _NN_PREFIX = re.compile(r"^(\d+)_") def _read_manifest() -> list[str]: """Имена миграций из manifest; # comments и пустые строки игнорируются.""" names: list[str] = [] for raw in _MANIFEST.read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue names.append(line) return names def _actual_sql_files() -> set[str]: return {p.name for p in _SQL_DIR.glob("*.sql")} def _prefix(name: str) -> str | None: m = _NN_PREFIX.match(name) return m.group(1) if m else None def test_manifest_entries_all_exist() -> None: """Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/. Red => применённая миграция переименована или удалена. Прод трекает по bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается неприменённым и прогоняется повторно. Восстанови исходное имя файла (или, если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых миграций: заведи НОВЫЙ файл, а старый оставь как есть). """ actual = _actual_sql_files() manifest = _read_manifest() missing = sorted(n for n in manifest if n not in actual) assert not missing, ( "Миграции из _manifest_applied.txt отсутствуют в data/sql/ " f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде " "(tracking по bare-filename в _schema_migrations) — их нельзя " "переименовывать/удалять. Верни исходные имена файлов." ) def test_manifest_is_sorted_and_unique() -> None: """Manifest отсортирован (codepoint) и без дублей — детерминированный слепок.""" manifest = _read_manifest() assert manifest == sorted(manifest), ( "_manifest_applied.txt не отсортирован. Пересортируй записи " "(LC_ALL=C sort / Python sorted())." ) dupes = sorted({n for n in manifest if manifest.count(n) > 1}) assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}" def test_new_files_do_not_reuse_prefix() -> None: """Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс. Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому не флагаются: считаются "существующими", а не "новыми". Red => новый файл взял префикс уже присутствующей миграции. Присвой следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR). """ actual = _actual_sql_files() manifest = set(_read_manifest()) # Префиксы, «занятые» уже-применёнными (manifest) миграциями. baseline_prefixes: set[str] = set() for name in manifest: p = _prefix(name) if p is not None: baseline_prefixes.add(p) new_files = sorted(actual - manifest) collisions: list[str] = [] # Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN). seen_new_prefix: dict[str, str] = {} for name in new_files: p = _prefix(name) if p is None: continue if p in baseline_prefixes: collisions.append(f"{name} (префикс {p} занят применённой миграцией)") elif p in seen_new_prefix: collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})") else: seen_new_prefix[p] = name assert not collisions, ( "Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". " "Присвой следующий свободный номер и добавь имя файла в " "_manifest_applied.txt в ЭТОМ ЖЕ PR." ) def test_manifest_covers_all_but_new_files() -> None: """data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс — напоминание о self-maintenance: manifest дополняется вместе с миграцией. Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и что новые файлы не создают префикс-коллизий (см. предыдущий тест). """ # Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей. assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}" assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"