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
179 lines
7.0 KiB
Python
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
|