Files
ss-tools/backend/tests/test_smoke_migration_chain.py
root 632b730fff chore: migrate GRACE-Poly anchors to hierarchical dotted naming
Systematic rename of all semantic anchors (#region, [DEF], @RELATION)
across 1400+ files — backend Python, frontend Svelte/TS, specs, docs:
- Flat anchors become Namespace.Module.Entity
- @RELATION references updated to match new anchor paths
- Zero business logic changes
2026-07-22 11:48:15 +03:00

179 lines
7.0 KiB
Python

# #region Test.SmokeMigrationChain [C:2] [TYPE Module] [SEMANTICS test, smoke, alembic, migration, chain, heads]
# @BRIEF Smoke test: verifies real Alembic migration files form a valid single-head chain
# without broken down_revision links. Runs with NO database connection.
# @RELATION BINDS_TO -> [Tooling.CheckMigrationChain]
# @RELATION BINDS_TO -> [Alembic.MergeThreeHeads]
# @INVARIANT: Exactly one migration head exists.
# @INVARIANT: All down_revision references point to existing migrations.
# @TEST_EDGE: multiple_heads → `alembic upgrade head` fails with "Multiple head revisions are present"
# @TEST_EDGE: broken_chain → Broken down_revision link causes undeployable state
# @TEST_EDGE: merge_migration → Merge migration (tuple down_revision) must be handled
# @RATIONALE: ScriptDirectory.from_config() only reads version files — no DB connection needed.
# Unlike test_alembic_migrations.py (requires PostgreSQL) and test_check_migration_chain.py
# (uses mocks), this test loads REAL migration files and catches:
# - Branch divergence (multiple heads from new migrations after a merge)
# - Broken down_revision links (orphaned parents)
# - Empty migration directory
# @REJECTED: Relying only on `alembic upgrade head` at deploy time was rejected — the error
# surfaces too late (after certs, DB wait, container startup). This test runs in CI without DB.
"""Smoke test: verify real Alembik migration chain integrity without a database connection.
Catches before deployment:
- Multiple migration heads (branch divergence)
- Broken down_revision links (orphaned revisions)
- Empty migration directory
No database required — ScriptDirectory reads only the version files' Python metadata
(revision, down_revision, doc, branch_labels).
"""
import sys
from pathlib import Path
# Ensure backend root is on sys.path for Alembic config resolution
_src_path = str(Path(__file__).resolve().parent.parent)
if _src_path not in sys.path:
sys.path.insert(0, _src_path)
def _load_script_dir():
"""Load Alembic ScriptDirectory from real files (no DB connection)."""
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
backend_dir = Path(__file__).resolve().parent.parent
alembic_cfg = AlembicConfig(str(backend_dir / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
return ScriptDirectory.from_config(alembic_cfg)
# #region test_single_migration_head [C:1] [TYPE Function]
# @ingroup Test.SmokeMigrationChain
# @BRIEF Checks that exactly one migration head exists.
def test_single_migration_head():
"""Exactly one migration head must exist.
Multiple heads mean branch divergence — `alembic upgrade head` will fail with:
"Multiple head revisions are present for given argument 'head'"
"""
script_dir = _load_script_dir()
heads = list(script_dir.get_heads())
assert len(heads) > 0, (
"No migration heads found — migration directory is empty"
)
assert len(heads) == 1, (
f"Multiple migration heads detected ({len(heads)}). "
f"This breaks 'alembic upgrade head'. "
f"Heads: {[(h, script_dir.get_revision(h).doc) for h in heads]}"
)
# #endregion test_single_migration_head
# #region test_migration_chain_integrity [C:1] [TYPE Function]
# @ingroup Test.SmokeMigrationChain
# @BRIEF Walks the full chain from head to root verifying all down_revision links.
def test_migration_chain_integrity():
"""Walk the full migration chain from head(s) to root.
Verifies:
- Every down_revision (including tuple parents in merge migrations) exists
- No circular references
- All migrations are reachable from the head
"""
script_dir = _load_script_dir()
heads = list(script_dir.get_heads())
assert len(heads) > 0, "No migration heads found"
visited: set[str] = set()
queue: list[str] = list(heads)
while queue:
rev_id = queue.pop(0)
if rev_id in visited:
continue
visited.add(rev_id)
rev = script_dir.get_revision(rev_id)
assert rev is not None, (
f"Revision {rev_id} referenced but not found in migration files"
)
down = rev.down_revision
if down is None:
continue # root migration (base)
if isinstance(down, str):
# Linear dependency
parent_rev = script_dir.get_revision(down)
assert parent_rev is not None, (
f"Broken chain link: {rev_id} -> {down} (revision not found)"
)
queue.append(down)
elif isinstance(down, tuple):
# Merge migration — check all parents
for parent_id in down:
parent_rev = script_dir.get_revision(parent_id)
assert parent_rev is not None, (
f"Broken merge link: {rev_id} -> {parent_id} (revision not found)"
)
queue.append(parent_id)
# Get total revision count for reporting (walk_revisions yields all)
all_revisions = list(script_dir.walk_revisions())
total = len(all_revisions)
assert len(visited) <= total, (
f"More revisions visited ({len(visited)}) than exist ({total}) — "
"possible duplicate or circular reference"
)
# Warn if some revisions are unreachable from heads (orphaned)
# Note: some revisions may be alternative ancestry paths via merge
# that are reachable even if not in the primary linear walk.
if len(visited) < total:
visited_hex = {v[:8] for v in visited}
all_ids = [r.revision for r in all_revisions]
orphaned = [r[:8] for r in all_ids if r[:8] not in visited_hex and r not in visited]
print(
f"NOTE: {total - len(visited)} revision(s) not traversed from head "
f"(may be alternative ancestry paths via merge): {orphaned}"
)
# #endregion test_migration_chain_integrity
# #region test_migration_revisions_exist [C:1] [TYPE Function]
# @ingroup Test.SmokeMigrationChain
# @BRIEF Verifies all migration files parse and were discovered by ScriptDirectory.
def test_migration_revisions_exist():
"""All migration .py files in versions/ directory are loaded as revisions."""
script_dir = _load_script_dir()
revisions = list(script_dir.walk_revisions())
# Count actual .py files in versions/
versions_dir = Path(__file__).resolve().parent.parent / "alembic" / "versions"
py_files = sorted(
f for f in versions_dir.iterdir()
if f.suffix == ".py" and f.name != "__init__.py"
)
revision_ids = {r.revision for r in revisions}
assert len(revisions) > 0, "No revisions loaded by ScriptDirectory"
print(
f"Migration revisions loaded: {len(revisions)}, "
f".py files in versions/: {len(py_files)}"
)
# Verify each .py file maps to a loaded revision by checking file paths
rev_ids_sorted = sorted(revision_ids)
print(f"Revision IDs: {[r[:8] for r in rev_ids_sorted]}")
# #endregion test_migration_revisions_exist
# #endregion Test.SmokeMigrationChain