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
138 lines
7.5 KiB
Python
138 lines
7.5 KiB
Python
"""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}"
|