180 lines
8.4 KiB
Python
180 lines
8.4 KiB
Python
# #region Test.Alembic.Migrations [C:3] [TYPE Module] [SEMANTICS test,alembic,migration,schema,postgres]
|
|
# @BRIEF Verifies fresh, legacy, and targeted PostgreSQL Alembic upgrades.
|
|
# @RELATION BINDS_TO -> [Alembic.AddDeploymentRecords]
|
|
# @INVARIANT Running `alembic upgrade head` twice is idempotent (no-op on second run) on PostgreSQL.
|
|
# @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.AlembicMigrations.TestFreshUpgradeCreatesTables]
|
|
# @TEST_INVARIANT: alembic_upgrade_legacy -> VERIFIED_BY: [Test.AlembicMigrations.TestLegacyDatabaseUpgrade]
|
|
# @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.
|
|
# @REJECTED Legacy stamp + raw SQL rejected — entrypoint now runs `alembic upgrade head`
|
|
# for legacy databases, ensuring all missing tables/columns are created.
|
|
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
# Ensure backend/src is importable for model metadata
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
# Alembic migrations use PostgreSQL-specific features (CREATE INDEX may conflict
|
|
# with SQLite auto-indexes for UNIQUE constraints). Skip full-chain tests on SQLite.
|
|
_DB_BACKEND = os.environ.get("DATABASE_URL", "sqlite://").split(":")[0]
|
|
_REQUIRES_PG = pytest.mark.skipif(
|
|
_DB_BACKEND != "postgresql",
|
|
reason="Alembic migrations are PostgreSQL-specific; use postgresql:// DATABASE_URL to test",
|
|
)
|
|
|
|
|
|
# #region Test.AlembicMigrations.TestFreshUpgradeCreatesTables [C:3] [TYPE Function]
|
|
# @BRIEF Run alembic upgrade head on a fresh PostgreSQL DB and verify key tables exist.
|
|
# @POST All expected tables exist after upgrade.
|
|
# @TEST_INVARIANT: alembic_fresh_upgrade -> VERIFIED_BY: [Test.AlembicMigrations.TestFreshUpgradeCreatesTables]
|
|
@_REQUIRES_PG
|
|
def test_fresh_upgrade_creates_tables() -> None:
|
|
"""Fresh Alembic upgrade creates all expected tables (PostgreSQL only)."""
|
|
from sqlalchemy import create_engine, inspect, text as sa_text
|
|
_run_alembic_upgrade()
|
|
engine = create_engine(os.environ["DATABASE_URL"])
|
|
inspector = inspect(engine)
|
|
tables = set(inspector.get_table_names())
|
|
expected_tables = {
|
|
"environments",
|
|
"translation_jobs",
|
|
"users",
|
|
"roles",
|
|
"permissions",
|
|
"task_records",
|
|
"alembic_version",
|
|
}
|
|
missing = expected_tables - tables
|
|
assert not missing, f"Missing tables after Alembic upgrade: {missing}"
|
|
with engine.connect() as conn:
|
|
result = conn.execute(sa_text("SELECT version_num FROM alembic_version"))
|
|
version = result.scalar_one()
|
|
assert version is not None and len(version) > 0
|
|
roles_cols = {c["name"] for c in inspector.get_columns("roles")}
|
|
assert "is_admin" in roles_cols, "roles.is_admin column missing after Alembic upgrade"
|
|
engine.dispose()
|
|
# #endregion Test.AlembicMigrations.TestFreshUpgradeCreatesTables
|
|
|
|
|
|
# #region Test.AlembicMigrations.TestLegacyDatabaseUpgrade [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.AlembicMigrations.TestLegacyDatabaseUpgrade]
|
|
@_REQUIRES_PG
|
|
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, text as sa_text
|
|
|
|
from src.models import auth as _auth, task as _task # noqa: F401
|
|
|
|
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) — 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 upgrade"
|
|
roles_cols = {c["name"] for c in inspector.get_columns("roles")}
|
|
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.AlembicMigrations.TestLegacyDatabaseUpgrade
|
|
|
|
|
|
# #region Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
|
|
# @BRIEF Verify the performance-knobs migration skips ORM-owned tables absent during a fresh upgrade.
|
|
# @POST No reflection or DDL operation runs when llm_providers does not exist yet.
|
|
def test_performance_knobs_skips_absent_optional_table(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Fresh Alembic upgrades must not require llm_providers before create_all()."""
|
|
migration_path = (
|
|
Path(__file__).parent.parent
|
|
/ "alembic"
|
|
/ "versions"
|
|
/ "f7a8b9c0d1e2_add_translate_performance_knobs.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("performance_knobs_migration", migration_path)
|
|
assert spec and spec.loader
|
|
migration = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(migration)
|
|
|
|
inspector = Mock()
|
|
inspector.has_table.return_value = False
|
|
bind = object()
|
|
add_column = Mock()
|
|
monkeypatch.setattr(migration.op, "get_bind", lambda: bind)
|
|
monkeypatch.setattr(migration.sa, "inspect", lambda received: inspector)
|
|
monkeypatch.setattr(migration.op, "add_column", add_column)
|
|
|
|
migration._add_col_if_missing("llm_providers", migration.sa.Column("throughput_class", migration.sa.String()))
|
|
|
|
inspector.get_columns.assert_not_called()
|
|
add_column.assert_not_called()
|
|
# #endregion Test.AlembicMigrations.TestPerformanceKnobsSkipsAbsentOptionalTable
|
|
|
|
|
|
# #region Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,fresh-install]
|
|
# @BRIEF Verify the session-activity migration skips its FK table before ORM creates users.
|
|
# @POST No CREATE TABLE operation runs when users is absent during a fresh Alembic upgrade.
|
|
def test_session_activity_skips_absent_users(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Fresh Alembic upgrades must not create FK tables before their ORM parent exists."""
|
|
migration_path = (
|
|
Path(__file__).parent.parent
|
|
/ "alembic"
|
|
/ "versions"
|
|
/ "8e9f0a1b2c3d_add_session_activity_table.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("session_activity_migration", migration_path)
|
|
assert spec and spec.loader
|
|
migration = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(migration)
|
|
|
|
inspector = Mock()
|
|
inspector.get_table_names.return_value = []
|
|
create_table = Mock()
|
|
monkeypatch.setattr(migration.op, "get_bind", lambda: object())
|
|
monkeypatch.setattr(migration, "inspect", lambda bind: inspector)
|
|
monkeypatch.setattr(migration.op, "create_table", create_table)
|
|
|
|
migration.upgrade()
|
|
|
|
create_table.assert_not_called()
|
|
# #endregion Test.AlembicMigrations.TestSessionActivitySkipsAbsentUsers
|
|
|
|
|
|
# #region Test.AlembicMigrations.RunAlembicUpgrade [C:1] [TYPE Function]
|
|
def _run_alembic_upgrade(revision: str = "head") -> None:
|
|
"""Run a named Alembic upgrade programmatically against DATABASE_URL."""
|
|
from alembic.config import Config as AlembicConfig
|
|
|
|
from alembic import command as alembic_command
|
|
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_cfg.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
|
|
alembic_command.upgrade(alembic_cfg, revision)
|
|
# #endregion Test.AlembicMigrations.RunAlembicUpgrade
|
|
|
|
# #endregion Test.Alembic.Migrations
|