alembic fix

This commit is contained in:
2026-06-01 16:34:07 +03:00
parent e12a9ddfce
commit 4c5da7e4d9
15 changed files with 346 additions and 90 deletions

View File

@@ -1,16 +1,17 @@
# #region TestAlembicMigrations [C:3] [TYPE Module] [SEMANTICS tests, alembic, migration, schema, idempotent]
# @BRIEF Verifies Alembic migration chain: fresh upgrade and legacy stamp.
# @BRIEF Verifies Alembic migration chain: fresh upgrade and legacy upgrade.
# @RELATION BINDS_TO -> [ed310b33f02c]
# @INVARIANT Running `alembic upgrade head` twice is idempotent (no-op on second run) on PostgreSQL.
# @INVARIANT Legacy database with existing tables can be stamped after adding boot-critical columns.
# @INVARIANT Legacy database with existing tables can be upgraded via `alembic upgrade head`.
# @PRE Alembic is installed, migration files exist in alembic/versions/.
# @POST Schema matches expected state after upgrade.
# @TEST_INVARIANT: alembic_fresh_upgrade -> VERIFIED_BY: [test_fresh_upgrade_creates_tables]
# @TEST_INVARIANT: alembic_stamp_legacy -> VERIFIED_BY: [test_legacy_database_stamp]
# @TEST_INVARIANT: alembic_upgrade_legacy -> VERIFIED_BY: [test_legacy_database_upgrade]
# @REJECTED Full migration chain idempotency on SQLite — initial migration uses
# op.create_index() which conflicts with SQLite auto-created indexes
# for UNIQUE constraints. Migrations are designed for PostgreSQL.
# Legacy databases use stamp + selective raw SQL in entrypoint instead.
# @REJECTED Legacy stamp + raw SQL rejected — entrypoint now runs `alembic upgrade head`
# for legacy databases, ensuring all missing tables/columns are created.
import os
import sys
@@ -65,51 +66,43 @@ def test_fresh_upgrade_creates_tables() -> None:
# #endregion test_fresh_upgrade_creates_tables
# #region test_legacy_database_stamp [C:2] [TYPE Function]
# @BRIEF Pre-populate tables via create_all(), then verify entrypoint-style stamp works.
# @POST alembic_version created, is_admin column present after stamp + raw SQL.
# #region test_legacy_database_upgrade [C:2] [TYPE Function]
# @BRIEF Simulate legacy DB (tables exist, no alembic_version), verify upgrade head works.
# @POST alembic_version created, all migration columns present after upgrade.
# @TEST_INVARIANT: alembic_upgrade_legacy -> VERIFIED_BY: [test_legacy_database_upgrade]
@_REQUIRES_PG
def test_legacy_database_stamp() -> None:
"""Alembic stamp works on a database that already has tables (PostgreSQL only)."""
def test_legacy_database_upgrade() -> None:
"""Alembic upgrade head works on a database that already has tables but no alembic_version."""
from sqlalchemy import create_engine, inspect
from sqlalchemy import text as sa_text
from src.models.mapping import Base
from src.models import auth as _auth # noqa: F401
from src.models import task as _task # noqa: F401
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command
engine = create_engine(os.environ["DATABASE_URL"])
# Drop alembic_version if exists to simulate legacy state
with engine.begin() as conn:
conn.execute(sa_text("DROP TABLE IF EXISTS alembic_version CASCADE"))
engine.dispose()
# Tables already exist from previous runs (create_all)
# Add is_admin column (simulating entrypoint raw SQL)
engine = create_engine(os.environ["DATABASE_URL"])
with engine.begin() as conn:
inspector = inspect(conn)
roles_cols = {c["name"] for c in inspector.get_columns("roles")}
if "is_admin" not in roles_cols:
conn.execute(
sa_text("ALTER TABLE roles ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
)
conn.execute(sa_text("UPDATE roles SET is_admin = TRUE WHERE name = 'Admin'"))
engine.dispose()
# Stamp head (entrypoint behavior for legacy DB)
backend_dir = Path(__file__).parent.parent
alembic_cfg = AlembicConfig(str(backend_dir / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
alembic_command.stamp(alembic_cfg, "head")
# Tables already exist from previous runs (create_all) — this is the legacy state.
# Run upgrade head (new entrypoint behavior for legacy DB)
_run_alembic_upgrade()
# Verify
engine = create_engine(os.environ["DATABASE_URL"])
inspector = inspect(engine)
tables = set(inspector.get_table_names())
assert "alembic_version" in tables, "alembic_version table not created by stamp"
assert "alembic_version" in tables, "alembic_version table not created by upgrade"
roles_cols = {c["name"] for c in inspector.get_columns("roles")}
assert "is_admin" in roles_cols
assert "is_admin" in roles_cols, "roles.is_admin column missing after legacy upgrade"
# Verify version_num matches head
result = engine.connect().execute(sa_text("SELECT version_num FROM alembic_version"))
version = result.scalar_one()
assert version is not None and len(version) > 0
engine.dispose()
# #endregion test_legacy_database_stamp
# #endregion test_legacy_database_upgrade
# #region _run_alembic_upgrade [C:1] [TYPE Function]