gendesign/tradein-mvp/backend/tests/test_migrations_manifest.py
lekss361 0793571c4a
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
fix(tradein/deploy): миграции ДО подъёма app-контейнеров + manifest-инвариант (#2216) (#2228)
2026-07-02 21:03:37 +00:00

138 lines
7.5 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.

"""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}"