gabriel / musehub public
test_migrations_section53.py python
223 lines 8.6 KB
Raw
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