gabriel / musehub public
test_schema_parity.py python
228 lines 8.9 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """TDD — ORM models and alembic migrations must stay in sync.
2
3 Problem
4 -------
5 Migrations run locally when the dev server starts. They run on staging only
6 at deploy time. If someone adds a column to the ORM model and forgets to
7 write a migration, the local create_all-based test DB matches the model but
8 staging (migration-based) does not. The result is exactly the class of drift
9 that caused the created_at NOT NULL failure in production.
10
11 Fix
12 ---
13 Two automated gates:
14
15 S1 The alembic migration chain is unbroken — every revision has a parent
16 and they form a single DAG from the initial revision to head. A broken
17 chain means a migration was merged without updating down_revision.
18
19 S2 Applying all alembic migrations to a fresh database produces a schema
20 that matches Base.metadata exactly (zero compare_metadata diff). If an
21 ORM model change has no corresponding migration, S2 fails loudly.
22
23 How S2 works
24 ------------
25 1. Creates a disposable PostgreSQL database: musehub_parity_test.
26 2. Applies all migrations via alembic upgrade head.
27 3. Runs alembic's compare_metadata() against that schema.
28 4. Asserts the diff list is empty.
29 5. Drops the parity database.
30
31 This test must run against a real PostgreSQL instance. It is skipped if the
32 admin connection to postgres fails (e.g. in environments without Postgres).
33
34 Ignored diff types
35 ------------------
36 compare_metadata sometimes reports benign differences that reflect alembic
37 internals rather than real model drift:
38 - The alembic_version table itself.
39 - server_default expression string differences when the SQL text is
40 semantically identical (e.g. "now()" vs "CURRENT_TIMESTAMP").
41
42 These are filtered before the assertion.
43 """
44 from __future__ import annotations
45
46 import os
47 from pathlib import Path
48 from typing import Any
49
50 import pytest
51
52
53 # ---------------------------------------------------------------------------
54 # Helpers
55 # ---------------------------------------------------------------------------
56
57 _REPO_ROOT = Path(__file__).parent.parent
58 _TEST_DB_URL = os.environ.get(
59 "TEST_DATABASE_URL",
60 "postgresql+asyncpg://musehub:musehub@localhost:5434/musehub_test",
61 )
62 _PARITY_DBNAME = "musehub_parity_test"
63
64 # Derive URLs for admin connection (postgres DB) and parity DB.
65 _BASE_URL = _TEST_DB_URL.replace("+asyncpg", "").rsplit("/", 1)[0]
66 _ADMIN_URL = f"{_BASE_URL}/postgres"
67 _PARITY_URL_SYNC = f"{_BASE_URL}/{_PARITY_DBNAME}"
68 _PARITY_URL_ASYNC = f"{_TEST_DB_URL.rsplit('/', 1)[0]}/{_PARITY_DBNAME}"
69
70
71 def _is_benign_diff(diff_item: Any) -> bool:
72 """Return True for known-harmless compare_metadata entries to filter out."""
73 try:
74 # alembic wraps some diff items in an extra list, e.g. [('modify_comment', ...)]
75 item = diff_item[0] if isinstance(diff_item, list) and len(diff_item) == 1 and isinstance(diff_item[0], tuple) else diff_item
76 diff_type = item[0] if isinstance(item, (list, tuple)) else None
77 if diff_type == "add_table":
78 table = item[1]
79 if hasattr(table, "name") and table.name == "alembic_version":
80 return True
81 if diff_type == "modify_comment":
82 # Column comments are cosmetic metadata — they don't affect behaviour
83 # and may be added directly in SQL without a formal migration.
84 return True
85 if diff_type == "modify_default":
86 # server_default text differences that are semantically identical
87 # (e.g. alembic normalises "now()" differently across pg versions)
88 existing = str(item[3]) if len(item) > 3 else ""
89 generated = str(item[4]) if len(item) > 4 else ""
90 _NOW_VARIANTS = {"now()", "CURRENT_TIMESTAMP", "current_timestamp"}
91 if existing in _NOW_VARIANTS and generated in _NOW_VARIANTS:
92 return True
93 except (IndexError, TypeError):
94 pass
95 return False
96
97
98 # ---------------------------------------------------------------------------
99 # S1 — migration chain is unbroken
100 # ---------------------------------------------------------------------------
101
102 def test_s1_migration_chain_is_unbroken() -> None:
103 """Every alembic revision must have a resolvable parent forming a single DAG.
104
105 A broken chain (e.g. two revisions claiming the same down_revision) causes
106 alembic upgrade to fail with a confusing error and staging deploys to halt.
107 This test catches the break before it ships.
108 """
109 from alembic.config import Config
110 from alembic.script import ScriptDirectory
111
112 cfg = Config(str(_REPO_ROOT / "alembic.ini"))
113 scripts = ScriptDirectory.from_config(cfg)
114
115 # Collect all revisions and verify the chain can be walked to head.
116 # walk_revisions() raises if there are gaps or multiple heads (unless
117 # explicit merge points exist).
118 revisions = list(scripts.walk_revisions())
119 assert len(revisions) > 0, "No alembic revisions found — run alembic init"
120
121 # Verify there is exactly one head (or explicit merge point at the top).
122 heads = scripts.get_heads()
123 assert len(heads) == 1, (
124 f"Multiple alembic heads found: {heads}.\n"
125 "Create a merge migration: alembic merge -m 'merge heads' <rev1> <rev2>"
126 )
127
128
129 # ---------------------------------------------------------------------------
130 # S2 — migration-applied schema matches ORM models (zero compare_metadata diff)
131 # ---------------------------------------------------------------------------
132
133 @pytest.fixture(scope="module")
134 def parity_db_url() -> str: # type: ignore[return]
135 """Create a fresh DB, apply all migrations, yield its sync URL, then drop it."""
136 try:
137 import psycopg2 # type: ignore[import]
138 except ImportError:
139 pytest.skip("psycopg2 not available — skipping schema parity test")
140
141 # Create the parity database.
142 try:
143 admin_conn = psycopg2.connect(_ADMIN_URL, connect_timeout=5)
144 except Exception as exc:
145 pytest.skip(f"Cannot connect to postgres admin DB ({exc}) — skipping S2")
146
147 admin_conn.autocommit = True
148 cur = admin_conn.cursor()
149 # Terminate any stale connections then drop + recreate.
150 cur.execute(
151 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
152 f"WHERE datname = '{_PARITY_DBNAME}'"
153 )
154 cur.execute(f"DROP DATABASE IF EXISTS {_PARITY_DBNAME}")
155 cur.execute(f"CREATE DATABASE {_PARITY_DBNAME}")
156 cur.close()
157 admin_conn.close()
158
159 # Apply all migrations.
160 from alembic.config import Config
161 from alembic import command as alembic_command
162 import musehub.config as _mhconfig
163
164 original_db_url = _mhconfig.settings.database_url
165 _mhconfig.settings.database_url = _PARITY_URL_ASYNC
166 try:
167 cfg = Config(str(_REPO_ROOT / "alembic.ini"))
168 # Also set it on the config object in case env.py reads from there.
169 cfg.set_main_option("sqlalchemy.url", _PARITY_URL_SYNC)
170 alembic_command.upgrade(cfg, "head")
171 finally:
172 _mhconfig.settings.database_url = original_db_url
173
174 yield _PARITY_URL_SYNC
175
176 # Teardown: drop the parity database.
177 try:
178 teardown_conn = psycopg2.connect(_ADMIN_URL, connect_timeout=5)
179 teardown_conn.autocommit = True
180 td_cur = teardown_conn.cursor()
181 td_cur.execute(
182 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
183 f"WHERE datname = '{_PARITY_DBNAME}'"
184 )
185 td_cur.execute(f"DROP DATABASE IF EXISTS {_PARITY_DBNAME}")
186 td_cur.close()
187 teardown_conn.close()
188 except Exception:
189 pass # Best effort — don't fail the test suite on teardown
190
191
192 def test_s2_orm_models_match_migrations(parity_db_url: str) -> None:
193 """Applying all alembic migrations to a fresh DB must produce zero drift
194 from Base.metadata.
195
196 Fails if an ORM model has a column, index, or constraint that no migration
197 creates. This is the automated equivalent of running:
198
199 alembic upgrade head && alembic check
200
201 against a clean database.
202 """
203 from alembic.autogenerate import compare_metadata
204 from alembic.runtime.migration import MigrationContext
205 from sqlalchemy import create_engine
206
207 import musehub.db.musehub_models # noqa: F401 — register all models
208 import musehub.db.muse_cli_models # noqa: F401 — register CLI models
209 from musehub.db.database import Base
210
211 engine = create_engine(parity_db_url, connect_args={"connect_timeout": 10})
212 try:
213 with engine.connect() as conn:
214 ctx = MigrationContext.configure(conn, opts={"compare_type": True})
215 raw_diff = compare_metadata(ctx, Base.metadata)
216 finally:
217 engine.dispose()
218
219 meaningful_diff = [item for item in raw_diff if not _is_benign_diff(item)]
220
221 assert meaningful_diff == [], (
222 "Schema drift detected: ORM models have changes not captured in migrations.\n\n"
223 "Diff items:\n"
224 + "\n".join(f" {item}" for item in meaningful_diff)
225 + "\n\n"
226 "Fix: run alembic revision --autogenerate -m 'describe the change' "
227 "and commit the generated migration file."
228 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago