Files
ss-tools/backend/tests/test_scenario_registry_migration.py

115 lines
5.1 KiB
Python

# #region Test.ScenarioRegistry.Migration [C:3] [TYPE Module] [SEMANTICS test,alembic,migration,scenario,registry,schema]
# @defgroup ScenarioRegistry Test.Migration Migration smoke tests — chain linkage + real upgrade/downgrade on SQLite.
# @LAYER Test
# @RELATION VERIFIES -> [Alembic.ScenarioRegistryTables]
# @TEST_EDGE: migration is the head; upgrade creates 4 tables with expected columns/FK; downgrade drops them.
# @RATIONALE The migration is written with portable DDL only (no PG-specific types), so a real
# upgrade/downgrade can run on in-memory SQLite via alembic.operations — stronger than mocks.
from __future__ import annotations
import importlib.util
from pathlib import Path
from sqlalchemy import create_engine, inspect
_MIGRATION_PATH = (
Path(__file__).parent.parent
/ "alembic"
/ "versions"
/ "v1w2x3y4z5a6_add_scenario_registry_tables.py"
)
_BACKEND_DIR = Path(__file__).parent.parent
_EXPECTED_TABLES = {
"scenario_registry_entries",
"scenario_revisions",
"scenario_staleness_signals",
"scenario_lifecycle_audit",
}
def _load_migration_module():
spec = importlib.util.spec_from_file_location("scenario_registry_migration", _MIGRATION_PATH)
assert spec and spec.loader
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
return migration
# #region Test.ScenarioRegistry.Migration.TestChain [C:2] [TYPE Class]
class TestChain:
"""The migration is wired into the existing alembic chain (links onto its predecessor)."""
def test_migration_is_in_chain_and_links_previous_head(self):
from alembic.config import Config
from alembic.script import ScriptDirectory
migration = _load_migration_module()
cfg = Config(str(_BACKEND_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(_BACKEND_DIR / "alembic"))
sd = ScriptDirectory.from_config(cfg)
assert migration.down_revision == "u1v2w3x4y5z6", "must chain onto the previous head"
# The revision is part of the single linear chain (no branches); it may no longer be the
# terminal head once later migrations (046 automation) append to the same chain.
chain = list(sd.walk_revisions("base", "head"))
revisions_in_chain = {rev.revision for rev in chain}
assert migration.revision in revisions_in_chain, "migration must be part of the linear chain"
assert len(sd.get_heads()) == 1, "chain must stay linear (single head)"
# #endregion Test.ScenarioRegistry.Migration.TestChain
# #region Test.ScenarioRegistry.Migration.TestUpgradeDowngrade [C:2] [TYPE Class]
class TestUpgradeDowngrade:
"""Real upgrade()/downgrade() run against in-memory SQLite."""
def test_upgrade_creates_registry_tables(self, monkeypatch):
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = _load_migration_module()
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
monkeypatch.setattr(migration, "op", Operations(ctx))
migration.upgrade()
tables = set(inspect(conn).get_table_names())
assert tables >= _EXPECTED_TABLES, f"missing: {_EXPECTED_TABLES - tables}"
entry_cols = {c["name"] for c in inspect(conn).get_columns("scenario_registry_entries")}
assert "current_revision_id" in entry_cols
assert "scenario_key" in entry_cols
assert "tags" in entry_cols
revision_cols = {c["name"] for c in inspect(conn).get_columns("scenario_revisions")}
assert {"revision_id", "scenario_id", "content_hash", "parent_revision_id",
"graph_snapshot", "activation_status"} <= revision_cols
signal_fks = inspect(conn).get_foreign_keys("scenario_staleness_signals")
assert any(fk["referred_table"] == "scenario_registry_entries" for fk in signal_fks)
entry_fks = inspect(conn).get_foreign_keys("scenario_registry_entries")
assert not any(
fk["referred_table"] == "scenario_revisions" for fk in entry_fks
), "current_revision_id must be a plain pointer — no circular FK"
unique = inspect(conn).get_unique_constraints("scenario_staleness_signals")
assert any("scenario_id" in u["column_names"] for u in unique)
def test_downgrade_drops_registry_tables(self, monkeypatch):
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = _load_migration_module()
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
monkeypatch.setattr(migration, "op", Operations(ctx))
migration.upgrade()
migration.downgrade()
tables = set(inspect(conn).get_table_names())
assert _EXPECTED_TABLES.isdisjoint(tables), f"still present: {_EXPECTED_TABLES & tables}"
# #endregion Test.ScenarioRegistry.Migration.TestUpgradeDowngrade
# #endregion Test.ScenarioRegistry.Migration