# #region TestCheckMigrationChain [C:2] [TYPE Module] [SEMANTICS test,alembic,migration,chain] # @BRIEF Tests for migration chain validation logic (mock alembic, no real DB). # @RELATION BINDS_TO -> [check_migration_chain] # @TEST_EDGE: check_migration_heads_single -> OK prints single head # @TEST_EDGE: check_migration_heads_multiple -> Exits 1 with error # @TEST_EDGE: check_migration_heads_empty -> Exits 1 with error # @TEST_EDGE: chain_integrity_ok -> Traverses clean chain # @TEST_EDGE: chain_integrity_broken_link -> Exits 1 # @TEST_EDGE: chain_integrity_merge_migration -> Handles tuple down_revision # @TEST_EDGE: chain_integrity_broken_merge -> Exits 1 # @TEST_EDGE: main_runs_all_checks -> Calls both functions # @TEST_EDGE: main_with_multiple_heads -> Exits early import sys from pathlib import Path from unittest.mock import MagicMock, patch, call import pytest # Ensure src is importable sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) # ============================================================================= # check_migration_heads # ============================================================================= class TestCheckMigrationHeads: """Tests for check_migration_chain.check_migration_heads().""" # Alembic imports are inside function body, so patch the real full paths @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_single_head(self, MockAlembicConfig, MockScriptDir, capsys): """Happy: single head prints OK.""" mock_script = MagicMock() mock_script.get_heads.return_value = ["abc123"] rev = MagicMock() rev.doc = "Initial migration" mock_script.get_revision.return_value = rev MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_heads check_migration_heads() captured = capsys.readouterr() assert "OK: Single migration head" in captured.out assert "abc123" in captured.out @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_multiple_heads_exits(self, MockAlembicConfig, MockScriptDir): """Edge: multiple heads prints error and exits 1.""" mock_script = MagicMock() mock_script.get_heads.return_value = ["head1", "head2"] rev1 = MagicMock() rev1.doc = "Migration A" rev2 = MagicMock() rev2.doc = "Migration B" def get_revision_side_effect(rev_id): return {"head1": rev1, "head2": rev2}[rev_id] mock_script.get_revision.side_effect = get_revision_side_effect MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_heads with pytest.raises(SystemExit) as exc: check_migration_heads() assert exc.value.code == 1 @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_no_heads_exits(self, MockAlembicConfig, MockScriptDir): """Edge: no heads prints error and exits 1.""" mock_script = MagicMock() mock_script.get_heads.return_value = [] MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_heads with pytest.raises(SystemExit) as exc: check_migration_heads() assert exc.value.code == 1 # ============================================================================= # check_migration_chain_integrity # ============================================================================= class TestCheckMigrationChainIntegrity: """Tests for check_migration_chain.check_migration_chain_integrity().""" def _make_revision(self, rev_id, down_revision, doc=""): rev = MagicMock() rev.revision = rev_id rev.down_revision = down_revision rev.doc = doc return rev @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_chain_ok(self, MockAlembicConfig, MockScriptDir, capsys): """Happy: linear chain traversed without error.""" head = self._make_revision("c", "b", "Third") middle = self._make_revision("b", "a", "Second") root = self._make_revision("a", None, "First") mock_script = MagicMock() mock_script.get_heads.return_value = ["c"] def get_revision(rev_id): mapping = {"c": head, "b": middle, "a": root} return mapping[rev_id] mock_script.get_revision.side_effect = get_revision MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_chain_integrity check_migration_chain_integrity() captured = capsys.readouterr() assert "Chain integrity verified" in captured.out assert "3 migrations traversed" in captured.out @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_broken_link_exits(self, MockAlembicConfig, MockScriptDir): """Edge: missing down_revision exits 1.""" head = self._make_revision("c", "bogus", "Broken") mock_script = MagicMock() mock_script.get_heads.return_value = ["c"] def get_revision(rev_id): if rev_id == "c": return head raise Exception(f"Revision {rev_id} not found") mock_script.get_revision.side_effect = get_revision MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_chain_integrity with pytest.raises(SystemExit) as exc: check_migration_chain_integrity() assert exc.value.code == 1 @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_merge_migration_handled(self, MockAlembicConfig, MockScriptDir, capsys): """Edge: merge migration (tuple down_revision) traversed correctly.""" merge = self._make_revision("merge", ("a", "b"), "Merge") parent_a = self._make_revision("a", None, "Root A") parent_b = self._make_revision("b", None, "Root B") mock_script = MagicMock() mock_script.get_heads.return_value = ["merge"] def get_revision(rev_id): mapping = {"merge": merge, "a": parent_a, "b": parent_b} return mapping[rev_id] mock_script.get_revision.side_effect = get_revision MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_chain_integrity check_migration_chain_integrity() captured = capsys.readouterr() assert "Chain integrity verified" in captured.out assert "3 migrations traversed" in captured.out @patch("alembic.script.ScriptDirectory") @patch("alembic.config.Config") def test_broken_merge_link_exits(self, MockAlembicConfig, MockScriptDir): """Edge: merge migration with missing parent exits 1.""" merge = self._make_revision("merge", ("a", "ghost"), "Merge") mock_script = MagicMock() mock_script.get_heads.return_value = ["merge"] def get_revision(rev_id): if rev_id == "merge": return merge if rev_id == "a": return self._make_revision("a", None, "Root A") raise Exception(f"Revision {rev_id} not found") mock_script.get_revision.side_effect = get_revision MockScriptDir.from_config.return_value = mock_script from src.scripts.check_migration_chain import check_migration_chain_integrity with pytest.raises(SystemExit) as exc: check_migration_chain_integrity() assert exc.value.code == 1 # ============================================================================= # main # ============================================================================= class TestMain: """Tests for check_migration_chain.main().""" @patch("src.scripts.check_migration_chain.check_migration_chain_integrity") @patch("src.scripts.check_migration_chain.check_migration_heads") def test_main_calls_both(self, mock_heads, mock_integrity, capsys): """Happy: main() calls both checks and prints pass.""" from src.scripts.check_migration_chain import main main() mock_heads.assert_called_once() mock_integrity.assert_called_once() captured = capsys.readouterr() assert "All checks passed" in captured.out @patch("src.scripts.check_migration_chain.check_migration_chain_integrity") @patch("src.scripts.check_migration_chain.check_migration_heads") def test_main_stops_on_heads_failure(self, mock_heads, mock_integrity): """Edge: main exits early if check_migration_heads fails.""" mock_heads.side_effect = SystemExit(1) from src.scripts.check_migration_chain import main with pytest.raises(SystemExit) as exc: main() assert exc.value.code == 1 mock_integrity.assert_not_called() # #endregion TestCheckMigrationChain