"""Миграция 0001 обязана точно соответствовать моделям. Расхождение между `Base.metadata` и миграцией обнаруживается только на живой БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки, nullability и индексы, которые создаёт миграция, с тем, что описано моделями. """ from __future__ import annotations import re from pathlib import Path import pytest from app.db.models import Base MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py" SOURCE = MIGRATION.read_text(encoding="utf-8") # Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы. MODEL_TABLES = set(Base.metadata.tables) def _migration_block(table: str) -> str: """Тело вызова op.create_table для указанной таблицы. Границей служит следующий op.create_table; для последней таблицы — конец файла. """ start = SOURCE.index(f'op.create_table(\n "{table}",') next_table = SOURCE.find("op.create_table(", start + 1) end = next_table if next_table != -1 else len(SOURCE) return SOURCE[start:end] def test_migration_creates_every_model_table() -> None: created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE)) assert created == MODEL_TABLES, ( f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; " f"только в миграции: {created - MODEL_TABLES}" ) def test_downgrade_drops_every_created_table() -> None: downgrade = SOURCE[SOURCE.index("def downgrade()") :] dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade)) assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}" @pytest.mark.parametrize("table_name", sorted(MODEL_TABLES)) def test_columns_match(table_name: str) -> None: block = _migration_block(table_name) in_migration = set(re.findall(r'sa\.Column\(\n?\s*"(\w+)"', block)) in_model = {column.name for column in Base.metadata.tables[table_name].columns} assert in_migration == in_model, ( f"Таблица {table_name}: только в модели {in_model - in_migration}, " f"только в миграции {in_migration - in_model}" ) @pytest.mark.parametrize("table_name", sorted(MODEL_TABLES)) def test_nullability_matches(table_name: str) -> None: block = _migration_block(table_name) model_table = Base.metadata.tables[table_name] for column in model_table.columns: match = re.search(rf'sa\.Column\(\n?\s*"{column.name}".*?\n', block) assert match, f"{table_name}.{column.name} отсутствует в миграции" migration_nullable = "nullable=True" in match.group(0) assert migration_nullable == column.nullable, ( f"{table_name}.{column.name}: в модели nullable={column.nullable}, " f"в миграции nullable={migration_nullable}" ) @pytest.mark.parametrize("table_name", sorted(MODEL_TABLES)) def test_indexes_match(table_name: str) -> None: model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes} migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE)) missing = model_indexes - migration_indexes assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"