test_migrations_section53.py
python
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago
| 1 | """Tests for checklist section 5.3 — Migrations. |
| 2 | |
| 3 | Covers (structural, no DB connection required): |
| 4 | - All schema changes are versioned Alembic migrations (linear chain, no gaps) |
| 5 | - Every migration has a real (non-empty) downgrade() implementation |
| 6 | - Migration env.py wraps execution in a transaction (begin_transaction) |
| 7 | - Migration script is executable (deploy/migrate-test.sh exists and is +x) |
| 8 | - Head revision matches expected constant |
| 9 | """ |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | import inspect |
| 13 | import os |
| 14 | import pathlib |
| 15 | import stat |
| 16 | import types |
| 17 | |
| 18 | import pytest |
| 19 | from alembic.config import Config |
| 20 | from alembic.script import ScriptDirectory |
| 21 | |
| 22 | _REPO_ROOT = pathlib.Path(__file__).parent.parent |
| 23 | _EXPECTED_HEAD_PREFIX = "0028" |
| 24 | _EXPECTED_MIGRATION_COUNT = 28 |
| 25 | |
| 26 | |
| 27 | # --------------------------------------------------------------------------- |
| 28 | # Helpers |
| 29 | # --------------------------------------------------------------------------- |
| 30 | |
| 31 | def _script_dir() -> ScriptDirectory: |
| 32 | cfg = Config(str(_REPO_ROOT / "alembic.ini")) |
| 33 | cfg.set_main_option("script_location", str(_REPO_ROOT / "alembic")) |
| 34 | return ScriptDirectory.from_config(cfg) |
| 35 | |
| 36 | |
| 37 | def _is_stub_downgrade(mod: types.ModuleType) -> bool: |
| 38 | """Return True if downgrade() is a pass-only or single-ellipsis stub.""" |
| 39 | import ast |
| 40 | try: |
| 41 | src = inspect.getsource(getattr(mod, "downgrade")) |
| 42 | except (OSError, AttributeError): |
| 43 | return True |
| 44 | |
| 45 | # Strip the 'def downgrade...:' line and check what's left |
| 46 | lines = [l.strip() for l in src.splitlines() if l.strip() and not l.strip().startswith("def ")] |
| 47 | # A stub body is just 'pass', '...', or a docstring with nothing else |
| 48 | non_comment = [l for l in lines if not l.startswith("#") and not l.startswith('"""') and not l.startswith("'''")] |
| 49 | if not non_comment: |
| 50 | return True |
| 51 | if len(non_comment) == 1 and non_comment[0] in ("pass", "..."): |
| 52 | return True |
| 53 | return False |
| 54 | |
| 55 | |
| 56 | # --------------------------------------------------------------------------- |
| 57 | # Linear chain / versioning |
| 58 | # --------------------------------------------------------------------------- |
| 59 | |
| 60 | def test_migration_chain_is_linear() -> None: |
| 61 | """Migration graph must have exactly one head (no branches).""" |
| 62 | heads = _script_dir().get_heads() |
| 63 | assert len(heads) == 1, ( |
| 64 | f"Expected single-head chain, got {len(heads)} heads: {heads}. " |
| 65 | "Resolve the branch before merging." |
| 66 | ) |
| 67 | |
| 68 | |
| 69 | def test_migration_count_matches_expected() -> None: |
| 70 | """Migration count must equal _EXPECTED_MIGRATION_COUNT. |
| 71 | |
| 72 | Update _EXPECTED_MIGRATION_COUNT here when adding a new migration. |
| 73 | """ |
| 74 | revisions = list(_script_dir().walk_revisions()) |
| 75 | assert len(revisions) == _EXPECTED_MIGRATION_COUNT, ( |
| 76 | f"Expected {_EXPECTED_MIGRATION_COUNT} migrations, found {len(revisions)}. " |
| 77 | "Update _EXPECTED_MIGRATION_COUNT in this file." |
| 78 | ) |
| 79 | |
| 80 | |
| 81 | def test_head_revision_prefix() -> None: |
| 82 | """Head must start with the expected revision prefix.""" |
| 83 | heads = _script_dir().get_heads() |
| 84 | assert len(heads) == 1 |
| 85 | assert heads[0].startswith(_EXPECTED_HEAD_PREFIX), ( |
| 86 | f"Expected head starting with '{_EXPECTED_HEAD_PREFIX}', got '{heads[0]}'. " |
| 87 | "Update _EXPECTED_HEAD_PREFIX when a new migration is added." |
| 88 | ) |
| 89 | |
| 90 | |
| 91 | def test_all_migrations_importable() -> None: |
| 92 | """Every migration module must be importable without errors.""" |
| 93 | for rev in _script_dir().walk_revisions(): |
| 94 | assert rev.module is not None, ( |
| 95 | f"Revision {rev.revision} has no module — check for missing file." |
| 96 | ) |
| 97 | |
| 98 | |
| 99 | def test_revision_ids_are_sequential_integers() -> None: |
| 100 | """Revision IDs must be zero-padded 4-digit integers with no gaps.""" |
| 101 | revisions = sorted(_script_dir().walk_revisions(), key=lambda r: r.revision) |
| 102 | ids = sorted(int(r.revision[:4]) for r in revisions if r.revision[:4].isdigit()) |
| 103 | expected = list(range(1, len(ids) + 1)) |
| 104 | assert ids == expected, ( |
| 105 | f"Revision IDs are not sequential (no gaps): found {ids}, expected {expected}." |
| 106 | ) |
| 107 | |
| 108 | |
| 109 | # --------------------------------------------------------------------------- |
| 110 | # Downgrade coverage — every forward migration has a real downgrade |
| 111 | # --------------------------------------------------------------------------- |
| 112 | |
| 113 | def test_all_migrations_have_downgrade_function() -> None: |
| 114 | """Every migration module must define a downgrade() function.""" |
| 115 | for rev in _script_dir().walk_revisions(): |
| 116 | mod = rev.module |
| 117 | assert mod is not None |
| 118 | assert hasattr(mod, "downgrade"), ( |
| 119 | f"Revision {rev.revision} is missing a downgrade() function." |
| 120 | ) |
| 121 | |
| 122 | |
| 123 | def test_no_stub_downgrade_implementations() -> None: |
| 124 | """No migration may have a pass-only or ellipsis-only downgrade(). |
| 125 | |
| 126 | A stub downgrade makes rollback a silent no-op — forbidden. |
| 127 | """ |
| 128 | stubs = [] |
| 129 | for rev in _script_dir().walk_revisions(): |
| 130 | mod = rev.module |
| 131 | if mod is not None and _is_stub_downgrade(mod): |
| 132 | stubs.append(rev.revision) |
| 133 | |
| 134 | assert not stubs, ( |
| 135 | f"Migrations with stub (non-functional) downgrade(): {stubs}. " |
| 136 | "Implement the actual rollback DDL." |
| 137 | ) |
| 138 | |
| 139 | |
| 140 | def test_every_migration_references_tables_in_downgrade() -> None: |
| 141 | """Migrations that add a column or table must also reference it in downgrade(). |
| 142 | |
| 143 | Heuristic: if upgrade() calls op.add_column / op.create_table for table X, |
| 144 | downgrade() must reference X (via drop_column / drop_table). |
| 145 | Checked by source inspection — not exhaustive, but catches obvious omissions. |
| 146 | """ |
| 147 | import re |
| 148 | |
| 149 | _ADD_RE = re.compile(r'op\.(add_column|create_table)\(\s*["\'](\w+)["\']') |
| 150 | _DROP_RE = re.compile(r'op\.(drop_column|drop_table)\(\s*["\'](\w+)["\']') |
| 151 | |
| 152 | violations = [] |
| 153 | for rev in _script_dir().walk_revisions(): |
| 154 | mod = rev.module |
| 155 | if mod is None: |
| 156 | continue |
| 157 | try: |
| 158 | up_src = inspect.getsource(getattr(mod, "upgrade")) |
| 159 | down_src = inspect.getsource(getattr(mod, "downgrade")) |
| 160 | except (OSError, AttributeError): |
| 161 | continue |
| 162 | |
| 163 | tables_added = {m.group(2) for m in _ADD_RE.finditer(up_src)} |
| 164 | tables_dropped = {m.group(2) for m in _DROP_RE.finditer(down_src)} |
| 165 | missing = tables_added - tables_dropped |
| 166 | if missing: |
| 167 | violations.append(f"{rev.revision}: added {missing} but downgrade() doesn't drop them") |
| 168 | |
| 169 | assert not violations, ( |
| 170 | "Migrations with incomplete downgrade():\n" + "\n".join(violations) |
| 171 | ) |
| 172 | |
| 173 | |
| 174 | # --------------------------------------------------------------------------- |
| 175 | # Transaction safety — env.py wraps migrations in a transaction |
| 176 | # --------------------------------------------------------------------------- |
| 177 | |
| 178 | def test_env_py_uses_begin_transaction() -> None: |
| 179 | """alembic/env.py must call context.begin_transaction() for both offline and online runs. |
| 180 | |
| 181 | This ensures that a failed migration rolls back cleanly instead of leaving |
| 182 | the schema in a partially-applied state. |
| 183 | """ |
| 184 | env_path = _REPO_ROOT / "alembic" / "env.py" |
| 185 | src = env_path.read_text() |
| 186 | count = src.count("context.begin_transaction()") |
| 187 | assert count >= 2, ( |
| 188 | f"Expected at least 2 calls to context.begin_transaction() in env.py " |
| 189 | f"(one for offline, one for online), found {count}. " |
| 190 | "Wrap both run_migrations_offline() and do_run_migrations() in a transaction." |
| 191 | ) |
| 192 | |
| 193 | |
| 194 | # --------------------------------------------------------------------------- |
| 195 | # Prod-snapshot migration test script |
| 196 | # --------------------------------------------------------------------------- |
| 197 | |
| 198 | def test_migrate_test_script_exists_and_is_executable() -> None: |
| 199 | """deploy/migrate-test.sh must exist and be executable. |
| 200 | |
| 201 | This script is the mechanism for testing migrations against a production |
| 202 | data snapshot before applying to production (checklist item 5.3.2). |
| 203 | """ |
| 204 | script = _REPO_ROOT / "deploy" / "migrate-test.sh" |
| 205 | assert script.exists(), ( |
| 206 | "deploy/migrate-test.sh is missing. " |
| 207 | "This script is required to validate migrations against a prod snapshot." |
| 208 | ) |
| 209 | mode = script.stat().st_mode |
| 210 | assert mode & stat.S_IXUSR, ( |
| 211 | "deploy/migrate-test.sh must be executable (chmod +x)." |
| 212 | ) |
| 213 | |
| 214 | |
| 215 | def test_migrate_test_script_contains_round_trip() -> None: |
| 216 | """deploy/migrate-test.sh must perform an upgrade→downgrade→upgrade round-trip.""" |
| 217 | script = (_REPO_ROOT / "deploy" / "migrate-test.sh").read_text() |
| 218 | assert "upgrade head" in script, "script must run 'alembic upgrade head'" |
| 219 | assert "downgrade" in script, "script must run 'alembic downgrade' step" |
| 220 | # Must do upgrade twice (initial + after downgrade round-trip) |
| 221 | assert script.count("upgrade head") >= 2, ( |
| 222 | "script must upgrade to HEAD twice (initial apply + round-trip after downgrade)" |
| 223 | ) |
File History
1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago