"""Alembic migration structural tests. Validates the migration chain without executing SQL against a live database. Uses ``ScriptDirectory`` inspection to check the revision graph, head revision, module imports, and CI migration table definitions — all without connecting to a database engine. Structural inspection is used (not live SQL) because some migrations use PostgreSQL-specific SQL (``now()``, JSON casts, etc.) and editing applied migrations is forbidden. """ from __future__ import annotations import importlib import pathlib import types from alembic.config import Config from alembic.script import ScriptDirectory # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- _REPO_ROOT = pathlib.Path(__file__).parent.parent _EXPECTED_HEAD = "0069" _EXPECTED_MIGRATION_COUNT = 69 # Core tables that the consolidated schema migration (0001) must create _CORE_TABLES = { "musehub_repos", "musehub_identities", } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _script_dir() -> ScriptDirectory: """Return a ScriptDirectory for the project migrations.""" cfg = Config(str(_REPO_ROOT / "alembic.ini")) cfg.set_main_option("script_location", str(_REPO_ROOT / "alembic")) return ScriptDirectory.from_config(cfg) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_alembic_migration_chain_has_single_head() -> None: """The migration graph must have exactly one head revision (linear chain).""" heads = _script_dir().get_heads() assert len(heads) == 1, ( f"Expected a single-head migration chain, found {len(heads)} heads: {heads}. " "Branching migrations require manual resolution before merging." ) def test_alembic_migration_count() -> None: """The migration chain must contain exactly the expected number of revisions.""" revisions = list(_script_dir().walk_revisions()) assert len(revisions) == _EXPECTED_MIGRATION_COUNT, ( f"Expected {_EXPECTED_MIGRATION_COUNT} migrations, " f"found {len(revisions)}: {[r.revision for r in revisions]}. " "Update _EXPECTED_MIGRATION_COUNT when adding a new migration." ) def test_alembic_head_revision_is_latest() -> None: """The current head revision must match the expected head.""" heads = _script_dir().get_heads() assert len(heads) == 1 assert heads[0].startswith(_EXPECTED_HEAD), ( f"Expected head to start with '{_EXPECTED_HEAD}', got '{heads[0]}'. " "Update _EXPECTED_HEAD when a new migration is added." ) def test_alembic_all_revisions_importable() -> None: """Every migration module can be imported; no NameError or ImportError.""" script_dir = _script_dir() revisions = list(script_dir.walk_revisions()) assert len(revisions) > 0, "No revisions found — check alembic script_location." for rev in revisions: assert rev.revision is not None assert rev.module is not None, ( f"Revision {rev.revision} has no module — the migration file may be missing." ) def test_alembic_schema_migration_defines_core_tables() -> None: """Migration 0001 upgrade() must reference core tables.""" script_dir = _script_dir() revisions = list(script_dir.walk_revisions()) rev = next( (r for r in revisions if r.revision.startswith("0001")), None, ) assert rev is not None, "Revision 0001 not found — consolidated schema migration is missing." assert rev.module is not None mod: types.ModuleType = rev.module import inspect upgrade_src = inspect.getsource(getattr(mod, "upgrade")) missing = {t for t in _CORE_TABLES if t not in upgrade_src} assert not missing, ( f"Schema migration 0001 upgrade() does not reference: {missing}. " "Ensure all core tables are created in the migration." ) def test_alembic_schema_migration_downgrade_drops_core_tables() -> None: """Migration 0001 downgrade() must drop core tables.""" script_dir = _script_dir() revisions = list(script_dir.walk_revisions()) rev = next( (r for r in revisions if r.revision.startswith("0001")), None, ) assert rev is not None, "Revision 0001 not found." assert rev.module is not None mod: types.ModuleType = rev.module import inspect downgrade_src = inspect.getsource(getattr(mod, "downgrade")) missing = {t for t in _CORE_TABLES if t not in downgrade_src} assert not missing, ( f"Schema migration 0001 downgrade() does not reference: {missing}. " "Ensure all core tables are dropped in the downgrade." )