Files
lux_fiscal/backend/tests/test_migration_matches_models.py
T
lauadminandClaude Sonnet 5 2664cb8213 Initial commit: backend scaffold, auth, frontend login
- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:07:15 +03:00

84 lines
3.7 KiB
Python

"""Миграция 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}"