test_migrations_section36.py
python
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago
| 1 | """Section 36 — Database Migrations / Alembic (7-layer test suite). |
| 2 | |
| 3 | Covers: |
| 4 | alembic/versions/0001_consolidated_schema.py → 0019_drop_issue_comment_state_refs.py |
| 5 | 19 migrations, linear chain (no branches) |
| 6 | |
| 7 | Key design decisions: |
| 8 | - Tests require a real PostgreSQL instance (migrations use now(), JSON casts, etc.). |
| 9 | The DB URL is: |
| 10 | postgresql+asyncpg://musehub:musehub@localhost:5434/musehub_migration_test |
| 11 | - Each test creates a FRESH database (drop + create) so tests are fully isolated. |
| 12 | - The `migration_db_url` fixture is module-scoped: the fresh DB is created once |
| 13 | per module; individual test groups share it but leave the DB at a known |
| 14 | revision (HEAD) after each test. |
| 15 | - Tests that need the DB at a specific intermediate revision use |
| 16 | `_run_to(cfg, rev)` helpers that upgrade/downgrade as needed. |
| 17 | - **Never run the full test suite in CI** — these tests connect to the real |
| 18 | Postgres container. Run with: |
| 19 | python -m pytest tests/test_migrations_section36.py -x -q |
| 20 | |
| 21 | Connection note: |
| 22 | The postgres container is exposed at localhost:5434 (mapped from 5432). |
| 23 | Credentials: musehub / musehub. |
| 24 | """ |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import asyncio |
| 28 | import time |
| 29 | import uuid |
| 30 | from collections.abc import AsyncGenerator, Generator |
| 31 | from typing import Any |
| 32 | |
| 33 | import pytest |
| 34 | import pytest_asyncio |
| 35 | from sqlalchemy import inspect, text |
| 36 | from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine |
| 37 | from sqlalchemy.pool import NullPool |
| 38 | |
| 39 | # ── constants ───────────────────────────────────────────────────────────────── |
| 40 | |
| 41 | _PG_BASE_URL = "postgresql+asyncpg://musehub:musehub@localhost:5434" |
| 42 | _TEST_DB = "musehub_migration_test_s36" |
| 43 | _TEST_URL = f"{_PG_BASE_URL}/{_TEST_DB}" |
| 44 | _ADMIN_URL = f"{_PG_BASE_URL}/musehub" # connect to main DB to create/drop the test DB |
| 45 | |
| 46 | _ALL_REVISIONS = [ |
| 47 | "0001", "0002", "0003", "0004", "0005", "0006", "0007", "0008", |
| 48 | "0009", "0010", "0011", "0012", "0013", "0014", "0015", "0016", |
| 49 | "0017", "0018", "0019", "0020", "0021", "0022", "0023", "0024", |
| 50 | "0025", "0026", "0027", "0028", |
| 51 | ] |
| 52 | _HEAD = "0028" |
| 53 | |
| 54 | # Tables that MUST exist after a full upgrade to HEAD |
| 55 | # CI tables (musehub_ci_*) are excluded — dropped by migration 0024. |
| 56 | _REQUIRED_TABLES = { |
| 57 | "alembic_version", |
| 58 | "muse_commits", "muse_objects", "muse_snapshots", "muse_tags", |
| 59 | "musehub_auth_keys", "musehub_branches", "musehub_collaborators", |
| 60 | "musehub_commits", "musehub_coord_records", |
| 61 | "musehub_coord_reservations", "musehub_coord_tasks", |
| 62 | "musehub_domain_installs", "musehub_domains", |
| 63 | "musehub_identities", "musehub_issues", "musehub_labels", |
| 64 | "musehub_objects", "musehub_proposals", "musehub_releases", |
| 65 | "musehub_repos", "musehub_sessions", "musehub_snapshot_entries", |
| 66 | "musehub_snapshots", "musehub_symbol_index", |
| 67 | "musehub_wire_tags", |
| 68 | } |
| 69 | |
| 70 | # Tables that MUST be absent after a full upgrade (dropped by 0016) |
| 71 | _LEGACY_TABLES = {"muse_users", "muse_access_tokens", "musehub_profiles"} |
| 72 | |
| 73 | |
| 74 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 75 | |
| 76 | |
| 77 | def _run_alembic(db_url: str, *args: str) -> None: |
| 78 | """Run an alembic command in a subprocess to avoid the settings lru_cache. |
| 79 | |
| 80 | The in-process lru_cache on musehub.config.settings is populated from |
| 81 | conftest.py before DATABASE_URL is set; running in a subprocess guarantees |
| 82 | a clean settings instance that picks up our DATABASE_URL. |
| 83 | """ |
| 84 | import subprocess |
| 85 | import sys |
| 86 | |
| 87 | env = { |
| 88 | "DATABASE_URL": db_url, |
| 89 | "MUSE_ENV": "test", |
| 90 | "PATH": "/usr/bin:/bin:/usr/local/bin", |
| 91 | } |
| 92 | result = subprocess.run( |
| 93 | [sys.executable, "-m", "alembic"] + list(args), |
| 94 | cwd="/Users/gabriel/musehub", |
| 95 | env=env, |
| 96 | capture_output=True, |
| 97 | text=True, |
| 98 | timeout=120, |
| 99 | ) |
| 100 | if result.returncode != 0: |
| 101 | raise RuntimeError( |
| 102 | f"alembic {' '.join(args)} failed:\n{result.stderr[-2000:]}" |
| 103 | ) |
| 104 | |
| 105 | |
| 106 | def _upgrade(db_url: str, revision: str = "head") -> None: |
| 107 | _run_alembic(db_url, "upgrade", revision) |
| 108 | |
| 109 | |
| 110 | def _downgrade(db_url: str, revision: str) -> None: |
| 111 | _run_alembic(db_url, "downgrade", revision) |
| 112 | |
| 113 | |
| 114 | def _current_rev(db_url: str) -> str | None: |
| 115 | from alembic.runtime.migration import MigrationContext |
| 116 | from sqlalchemy import create_engine |
| 117 | |
| 118 | sync_url = db_url.replace("+asyncpg", "") |
| 119 | engine = create_engine(sync_url) |
| 120 | with engine.connect() as conn: |
| 121 | ctx = MigrationContext.configure(conn) |
| 122 | rev = ctx.get_current_revision() |
| 123 | engine.dispose() |
| 124 | return rev |
| 125 | |
| 126 | |
| 127 | async def _tables(engine: AsyncEngine) -> set[str]: |
| 128 | async with engine.connect() as conn: |
| 129 | result = await conn.execute( |
| 130 | text( |
| 131 | "SELECT tablename FROM pg_tables " |
| 132 | "WHERE schemaname='public' ORDER BY tablename" |
| 133 | ) |
| 134 | ) |
| 135 | return {row[0] for row in result} |
| 136 | |
| 137 | |
| 138 | async def _indexes_for(engine: AsyncEngine, table: str) -> set[str]: |
| 139 | async with engine.connect() as conn: |
| 140 | result = await conn.execute( |
| 141 | text( |
| 142 | "SELECT indexname FROM pg_indexes " |
| 143 | "WHERE schemaname='public' AND tablename = :t" |
| 144 | ), |
| 145 | {"t": table}, |
| 146 | ) |
| 147 | return {row[0] for row in result} |
| 148 | |
| 149 | |
| 150 | async def _columns(engine: AsyncEngine, table: str) -> set[str]: |
| 151 | async with engine.connect() as conn: |
| 152 | result = await conn.execute( |
| 153 | text( |
| 154 | "SELECT column_name FROM information_schema.columns " |
| 155 | "WHERE table_schema='public' AND table_name = :t" |
| 156 | ), |
| 157 | {"t": table}, |
| 158 | ) |
| 159 | return {row[0] for row in result} |
| 160 | |
| 161 | |
| 162 | def _fresh_db() -> None: |
| 163 | """Drop and recreate the section-36 test database synchronously.""" |
| 164 | from sqlalchemy import create_engine, text as stext |
| 165 | |
| 166 | sync_admin = _ADMIN_URL.replace("+asyncpg", "") |
| 167 | engine = create_engine(sync_admin, isolation_level="AUTOCOMMIT") |
| 168 | with engine.connect() as conn: |
| 169 | conn.execute( |
| 170 | stext(f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='{_TEST_DB}'") |
| 171 | ) |
| 172 | conn.execute(stext(f"DROP DATABASE IF EXISTS {_TEST_DB}")) |
| 173 | conn.execute(stext(f"CREATE DATABASE {_TEST_DB}")) |
| 174 | engine.dispose() |
| 175 | |
| 176 | |
| 177 | # ── module-scoped engine fixture ────────────────────────────────────────────── |
| 178 | |
| 179 | |
| 180 | @pytest.fixture(scope="module") |
| 181 | def migrated_engine() -> Generator[AsyncEngine, None, None]: |
| 182 | """Create a fresh DB, run all migrations to HEAD, yield an async engine. |
| 183 | |
| 184 | Module-scoped: created once for all tests in this file. |
| 185 | The DB is left at HEAD after each test (tests that downgrade must re-upgrade). |
| 186 | """ |
| 187 | _fresh_db() |
| 188 | _upgrade(_TEST_URL) |
| 189 | engine = create_async_engine(_TEST_URL, poolclass=NullPool) |
| 190 | yield engine |
| 191 | asyncio.run(engine.dispose()) |
| 192 | |
| 193 | |
| 194 | # ══════════════════════════════════════════════════════════════════════════════ |
| 195 | # 1. Unit |
| 196 | # ══════════════════════════════════════════════════════════════════════════════ |
| 197 | |
| 198 | |
| 199 | class TestMigrationUnit: |
| 200 | """Static analysis of migration files — no DB connection needed.""" |
| 201 | |
| 202 | def test_revision_chain_is_linear(self) -> None: |
| 203 | """Every migration (except 0001) has a down_revision pointing to its predecessor.""" |
| 204 | import importlib.util |
| 205 | import pathlib |
| 206 | |
| 207 | versions_dir = pathlib.Path("/Users/gabriel/musehub/alembic/versions") |
| 208 | revisions = {} |
| 209 | for f in versions_dir.glob("*.py"): |
| 210 | spec = importlib.util.spec_from_file_location(f.stem, f) |
| 211 | assert spec and spec.loader |
| 212 | mod = importlib.util.module_from_spec(spec) |
| 213 | assert spec.loader is not None |
| 214 | spec.loader.exec_module(mod) |
| 215 | rev = getattr(mod, "revision", None) |
| 216 | down = getattr(mod, "down_revision", None) |
| 217 | if rev: |
| 218 | revisions[str(rev)] = str(down) if down else None |
| 219 | |
| 220 | # 0001 has no predecessor |
| 221 | assert revisions.get("0001") is None |
| 222 | |
| 223 | # Every other migration's down_revision must exist as a revision |
| 224 | for rev, down in revisions.items(): |
| 225 | if down is not None: |
| 226 | assert down in revisions, ( |
| 227 | f"Migration {rev}: down_revision '{down}' not found" |
| 228 | ) |
| 229 | |
| 230 | def test_all_25_migrations_present(self) -> None: |
| 231 | import pathlib |
| 232 | |
| 233 | versions_dir = pathlib.Path("/Users/gabriel/musehub/alembic/versions") |
| 234 | files = {f.stem for f in versions_dir.glob("*.py") if not f.stem.startswith("__")} |
| 235 | assert len(files) == 28, f"Expected 28 migrations, found {len(files)}: {files}" |
| 236 | |
| 237 | def test_head_revision_is_0028(self) -> None: |
| 238 | import pathlib |
| 239 | |
| 240 | versions_dir = pathlib.Path("/Users/gabriel/musehub/alembic/versions") |
| 241 | # The head is the revision not referenced as any down_revision |
| 242 | import importlib.util |
| 243 | |
| 244 | all_revs: set[str] = set() |
| 245 | down_revs: set[str] = set() |
| 246 | for f in versions_dir.glob("*.py"): |
| 247 | spec = importlib.util.spec_from_file_location(f.stem, f) |
| 248 | assert spec and spec.loader |
| 249 | mod = importlib.util.module_from_spec(spec) |
| 250 | assert spec.loader is not None |
| 251 | spec.loader.exec_module(mod) |
| 252 | rev = getattr(mod, "revision", None) |
| 253 | down = getattr(mod, "down_revision", None) |
| 254 | if rev: |
| 255 | all_revs.add(str(rev)) |
| 256 | if down: |
| 257 | down_revs.add(str(down)) |
| 258 | heads = all_revs - down_revs |
| 259 | assert heads == {"0028"}, f"Expected head 0028, got: {heads}" |
| 260 | |
| 261 | def test_each_migration_has_upgrade_and_downgrade(self) -> None: |
| 262 | import pathlib, importlib.util |
| 263 | |
| 264 | versions_dir = pathlib.Path("/Users/gabriel/musehub/alembic/versions") |
| 265 | for f in versions_dir.glob("*.py"): |
| 266 | spec = importlib.util.spec_from_file_location(f.stem, f) |
| 267 | assert spec and spec.loader |
| 268 | mod = importlib.util.module_from_spec(spec) |
| 269 | assert spec.loader is not None |
| 270 | spec.loader.exec_module(mod) |
| 271 | assert callable(getattr(mod, "upgrade", None)), ( |
| 272 | f"{f.name} missing upgrade()" |
| 273 | ) |
| 274 | assert callable(getattr(mod, "downgrade", None)), ( |
| 275 | f"{f.name} missing downgrade()" |
| 276 | ) |
| 277 | |
| 278 | def test_alembic_ini_points_to_correct_script_location(self) -> None: |
| 279 | import configparser |
| 280 | |
| 281 | parser = configparser.ConfigParser() |
| 282 | parser.read("/Users/gabriel/musehub/alembic.ini") |
| 283 | script_location = parser.get("alembic", "script_location", fallback="") |
| 284 | assert "alembic" in script_location |
| 285 | |
| 286 | def test_env_py_imports_all_model_bases(self) -> None: |
| 287 | env_text = open("/Users/gabriel/musehub/alembic/env.py").read() |
| 288 | assert "from musehub.db.database import Base" in env_text |
| 289 | assert "target_metadata = Base.metadata" in env_text |
| 290 | |
| 291 | |
| 292 | # ══════════════════════════════════════════════════════════════════════════════ |
| 293 | # 2. Integration |
| 294 | # ══════════════════════════════════════════════════════════════════════════════ |
| 295 | |
| 296 | |
| 297 | class TestMigrationIntegration: |
| 298 | """Real DB — run migrations and inspect schema state.""" |
| 299 | |
| 300 | def test_upgrade_to_head_succeeds(self) -> None: |
| 301 | """Full migration chain from empty DB to HEAD completes without error.""" |
| 302 | _fresh_db() |
| 303 | _upgrade(_TEST_URL) |
| 304 | rev = _current_rev(_TEST_URL) |
| 305 | assert rev == _HEAD |
| 306 | |
| 307 | def test_current_revision_tracked_in_alembic_version( |
| 308 | self, migrated_engine: AsyncEngine |
| 309 | ) -> None: |
| 310 | async def _check() -> str | None: |
| 311 | async with migrated_engine.connect() as conn: |
| 312 | result = await conn.execute(text("SELECT version_num FROM alembic_version")) |
| 313 | row = result.fetchone() |
| 314 | return row[0] if row else None |
| 315 | |
| 316 | rev = asyncio.run(_check()) |
| 317 | assert rev == _HEAD |
| 318 | |
| 319 | def test_required_tables_exist_at_head( |
| 320 | self, migrated_engine: AsyncEngine |
| 321 | ) -> None: |
| 322 | tables = asyncio.run(_tables(migrated_engine)) |
| 323 | missing = _REQUIRED_TABLES - tables |
| 324 | assert not missing, f"Tables missing after upgrade to HEAD: {missing}" |
| 325 | |
| 326 | def test_legacy_tables_absent_at_head( |
| 327 | self, migrated_engine: AsyncEngine |
| 328 | ) -> None: |
| 329 | tables = asyncio.run(_tables(migrated_engine)) |
| 330 | present_legacy = _LEGACY_TABLES & tables |
| 331 | assert not present_legacy, f"Legacy tables still present after HEAD: {present_legacy}" |
| 332 | |
| 333 | def test_downgrade_then_upgrade_returns_to_head(self) -> None: |
| 334 | """Downgrade one step and re-upgrade — must return to HEAD cleanly.""" |
| 335 | _downgrade(_TEST_URL, "0018") |
| 336 | assert _current_rev(_TEST_URL) == "0018" |
| 337 | _upgrade(_TEST_URL, "head") |
| 338 | assert _current_rev(_TEST_URL) == _HEAD |
| 339 | |
| 340 | def test_full_downgrade_to_base_then_re_upgrade(self) -> None: |
| 341 | """Downgrade all the way to base then re-upgrade — round trip works.""" |
| 342 | _downgrade(_TEST_URL, "base") |
| 343 | assert _current_rev(_TEST_URL) is None |
| 344 | _upgrade(_TEST_URL) |
| 345 | assert _current_rev(_TEST_URL) == _HEAD |
| 346 | |
| 347 | |
| 348 | # ══════════════════════════════════════════════════════════════════════════════ |
| 349 | # 3. End-to-End |
| 350 | # ══════════════════════════════════════════════════════════════════════════════ |
| 351 | |
| 352 | |
| 353 | class TestMigrationE2E: |
| 354 | """Full stack: migrate, insert data, downgrade, re-upgrade, verify data.""" |
| 355 | |
| 356 | def test_repo_data_survives_incremental_upgrade( |
| 357 | self, migrated_engine: AsyncEngine |
| 358 | ) -> None: |
| 359 | """Insert a repo row at HEAD, downgrade one step, re-upgrade — row survives.""" |
| 360 | |
| 361 | async def _run() -> None: |
| 362 | repo_id = str(uuid.uuid4()) |
| 363 | async with migrated_engine.connect() as conn: |
| 364 | await conn.execute( |
| 365 | text( |
| 366 | "INSERT INTO musehub_repos (repo_id, name, slug, owner, owner_user_id, " |
| 367 | "visibility, default_branch) " |
| 368 | "VALUES (:id, :n, :s, :o, :oid, 'public', 'main')" |
| 369 | ), |
| 370 | {"id": repo_id, "n": "e2e-repo", "s": "e2e-repo", "o": "testuser", "oid": "testuser"}, |
| 371 | ) |
| 372 | await conn.commit() |
| 373 | |
| 374 | _downgrade(_TEST_URL, "0018") |
| 375 | _upgrade(_TEST_URL) |
| 376 | |
| 377 | async with migrated_engine.connect() as conn: |
| 378 | result = await conn.execute( |
| 379 | text("SELECT repo_id FROM musehub_repos WHERE repo_id = :id"), |
| 380 | {"id": repo_id}, |
| 381 | ) |
| 382 | row = result.fetchone() |
| 383 | assert row is not None, "Repo row lost after downgrade/re-upgrade cycle" |
| 384 | |
| 385 | asyncio.run(_run()) |
| 386 | |
| 387 | def test_identity_row_survives_upgrade_chain( |
| 388 | self, migrated_engine: AsyncEngine |
| 389 | ) -> None: |
| 390 | """Insert an identity at HEAD; must still exist after a single-step cycle.""" |
| 391 | |
| 392 | async def _run() -> None: |
| 393 | identity_id = str(uuid.uuid4()) |
| 394 | async with migrated_engine.connect() as conn: |
| 395 | await conn.execute( |
| 396 | text( |
| 397 | "INSERT INTO musehub_identities (id, handle, display_name, identity_type) " |
| 398 | "VALUES (:id, :h, :dn, 'human')" |
| 399 | ), |
| 400 | {"id": identity_id, "h": f"e2e-user-{identity_id[:8]}", "dn": "E2E User"}, |
| 401 | ) |
| 402 | await conn.commit() |
| 403 | |
| 404 | _downgrade(_TEST_URL, "0018") |
| 405 | _upgrade(_TEST_URL) |
| 406 | |
| 407 | async with migrated_engine.connect() as conn: |
| 408 | result = await conn.execute( |
| 409 | text("SELECT id FROM musehub_identities WHERE id = :id"), |
| 410 | {"id": identity_id}, |
| 411 | ) |
| 412 | row = result.fetchone() |
| 413 | assert row is not None |
| 414 | |
| 415 | asyncio.run(_run()) |
| 416 | |
| 417 | def test_each_migration_step_is_individually_runnable(self) -> None: |
| 418 | """Upgrade one step at a time from base to HEAD — each step must succeed.""" |
| 419 | _downgrade(_TEST_URL, "base") |
| 420 | for rev in _ALL_REVISIONS: |
| 421 | _upgrade(_TEST_URL, rev) |
| 422 | actual = _current_rev(_TEST_URL) |
| 423 | assert actual == rev, f"After upgrading to {rev}, got revision {actual}" |
| 424 | |
| 425 | |
| 426 | # ══════════════════════════════════════════════════════════════════════════════ |
| 427 | # 4. Stress |
| 428 | # ══════════════════════════════════════════════════════════════════════════════ |
| 429 | |
| 430 | |
| 431 | class TestMigrationStress: |
| 432 | """Performance and repeated-execution scenarios.""" |
| 433 | |
| 434 | def test_full_upgrade_completes_under_60_seconds(self) -> None: |
| 435 | """25 migrations on an empty DB must complete in under 60 seconds.""" |
| 436 | _fresh_db() |
| 437 | start = time.perf_counter() |
| 438 | _upgrade(_TEST_URL) |
| 439 | elapsed = time.perf_counter() - start |
| 440 | assert elapsed < 60, f"Full upgrade took {elapsed:.1f}s (budget: 60s)" |
| 441 | |
| 442 | def test_full_downgrade_completes_under_60_seconds(self) -> None: |
| 443 | """Downgrade from HEAD to base must complete in under 60 seconds.""" |
| 444 | # DB is at HEAD from previous test |
| 445 | start = time.perf_counter() |
| 446 | _downgrade(_TEST_URL, "base") |
| 447 | elapsed = time.perf_counter() - start |
| 448 | assert elapsed < 60, f"Full downgrade took {elapsed:.1f}s (budget: 60s)" |
| 449 | # Restore HEAD for subsequent tests |
| 450 | _upgrade(_TEST_URL) |
| 451 | |
| 452 | def test_three_consecutive_upgrade_to_head_idempotent(self) -> None: |
| 453 | """Running upgrade head twice on an already-migrated DB is a no-op (idempotent).""" |
| 454 | assert _current_rev(_TEST_URL) == _HEAD |
| 455 | _upgrade(_TEST_URL) # Already at HEAD — must not error |
| 456 | assert _current_rev(_TEST_URL) == _HEAD |
| 457 | _upgrade(_TEST_URL) |
| 458 | assert _current_rev(_TEST_URL) == _HEAD |
| 459 | |
| 460 | def test_100_identity_inserts_survive_step_cycle( |
| 461 | self, migrated_engine: AsyncEngine |
| 462 | ) -> None: |
| 463 | """Insert 100 identity rows, do a single-step down/up, verify all persist.""" |
| 464 | |
| 465 | async def _run() -> None: |
| 466 | ids = [str(uuid.uuid4()) for _ in range(100)] |
| 467 | async with migrated_engine.connect() as conn: |
| 468 | for i, identity_id in enumerate(ids): |
| 469 | await conn.execute( |
| 470 | text( |
| 471 | "INSERT INTO musehub_identities (id, handle, display_name, identity_type) " |
| 472 | "VALUES (:id, :h, :dn, 'human')" |
| 473 | ), |
| 474 | {"id": identity_id, "h": f"stress-{i}-{identity_id[:6]}", "dn": f"Stress {i}"}, |
| 475 | ) |
| 476 | await conn.commit() |
| 477 | |
| 478 | _downgrade(_TEST_URL, "0018") |
| 479 | _upgrade(_TEST_URL) |
| 480 | |
| 481 | async with migrated_engine.connect() as conn: |
| 482 | result = await conn.execute( |
| 483 | text("SELECT COUNT(*) FROM musehub_identities WHERE id = ANY(:ids)"), |
| 484 | {"ids": ids}, |
| 485 | ) |
| 486 | count = result.scalar() |
| 487 | assert count == 100, f"Only {count}/100 identity rows survived the cycle" |
| 488 | |
| 489 | asyncio.run(_run()) |
| 490 | |
| 491 | |
| 492 | # ══════════════════════════════════════════════════════════════════════════════ |
| 493 | # 5. Data Integrity |
| 494 | # ══════════════════════════════════════════════════════════════════════════════ |
| 495 | |
| 496 | |
| 497 | class TestMigrationDataIntegrity: |
| 498 | """Schema correctness — columns, indexes, constraints at HEAD.""" |
| 499 | |
| 500 | def test_musehub_repos_has_required_columns( |
| 501 | self, migrated_engine: AsyncEngine |
| 502 | ) -> None: |
| 503 | cols = asyncio.run( |
| 504 | _columns(migrated_engine, "musehub_repos") |
| 505 | ) |
| 506 | for col in ("repo_id", "owner", "slug", "visibility", "default_branch"): |
| 507 | assert col in cols, f"musehub_repos missing column: {col}" |
| 508 | |
| 509 | def test_musehub_identities_has_agent_columns( |
| 510 | self, migrated_engine: AsyncEngine |
| 511 | ) -> None: |
| 512 | """Migration 0017 added spawned_by, scope, expires_at.""" |
| 513 | cols = asyncio.run( |
| 514 | _columns(migrated_engine, "musehub_identities") |
| 515 | ) |
| 516 | for col in ("spawned_by", "scope", "expires_at"): |
| 517 | assert col in cols, f"musehub_identities missing agent column: {col}" |
| 518 | |
| 519 | def test_musehub_identities_has_no_legacy_user_id( |
| 520 | self, migrated_engine: AsyncEngine |
| 521 | ) -> None: |
| 522 | """Migration 0016 removed legacy_user_id from musehub_identities.""" |
| 523 | cols = asyncio.run( |
| 524 | _columns(migrated_engine, "musehub_identities") |
| 525 | ) |
| 526 | assert "legacy_user_id" not in cols |
| 527 | |
| 528 | def test_musehub_issue_comments_has_no_state_refs( |
| 529 | self, migrated_engine: AsyncEngine |
| 530 | ) -> None: |
| 531 | """Migration 0019 dropped state_refs from musehub_issue_comments.""" |
| 532 | cols = asyncio.run( |
| 533 | _columns(migrated_engine, "musehub_issue_comments") |
| 534 | ) |
| 535 | assert "state_refs" not in cols |
| 536 | |
| 537 | def test_musehub_auth_keys_has_algorithm_column( |
| 538 | self, migrated_engine: AsyncEngine |
| 539 | ) -> None: |
| 540 | """Migration 0008 added the algorithm column to musehub_auth_keys.""" |
| 541 | cols = asyncio.run( |
| 542 | _columns(migrated_engine, "musehub_auth_keys") |
| 543 | ) |
| 544 | assert "algorithm" in cols |
| 545 | |
| 546 | def test_musehub_repos_unique_owner_slug_index_exists( |
| 547 | self, migrated_engine: AsyncEngine |
| 548 | ) -> None: |
| 549 | indexes = asyncio.run( |
| 550 | _indexes_for(migrated_engine, "musehub_repos") |
| 551 | ) |
| 552 | assert "uq_musehub_repos_owner_slug" in indexes |
| 553 | |
| 554 | def test_musehub_auth_keys_fingerprint_unique_index( |
| 555 | self, migrated_engine: AsyncEngine |
| 556 | ) -> None: |
| 557 | indexes = asyncio.run( |
| 558 | _indexes_for(migrated_engine, "musehub_auth_keys") |
| 559 | ) |
| 560 | assert "uq_musehub_auth_keys_fingerprint" in indexes |
| 561 | |
| 562 | def test_musehub_collaborators_has_identity_handle_column( |
| 563 | self, migrated_engine: AsyncEngine |
| 564 | ) -> None: |
| 565 | """Migration 0018 renamed user_id → identity_handle in collaborators.""" |
| 566 | cols = asyncio.run( |
| 567 | _columns(migrated_engine, "musehub_collaborators") |
| 568 | ) |
| 569 | assert "identity_handle" in cols |
| 570 | assert "user_id" not in cols |
| 571 | |
| 572 | def test_musehub_snapshot_entries_exists_and_has_key_columns( |
| 573 | self, migrated_engine: AsyncEngine |
| 574 | ) -> None: |
| 575 | """Migration 0013 replaced manifest blob with musehub_snapshot_entries table.""" |
| 576 | tables = asyncio.run(_tables(migrated_engine)) |
| 577 | assert "musehub_snapshot_entries" in tables |
| 578 | assert "musehub_snapshots" in tables # still exists, manifest column removed |
| 579 | |
| 580 | def test_wire_tags_table_exists( |
| 581 | self, migrated_engine: AsyncEngine |
| 582 | ) -> None: |
| 583 | tables = asyncio.run(_tables(migrated_engine)) |
| 584 | assert "musehub_wire_tags" in tables |
| 585 | |
| 586 | def test_sessions_table_exists( |
| 587 | self, migrated_engine: AsyncEngine |
| 588 | ) -> None: |
| 589 | tables = asyncio.run(_tables(migrated_engine)) |
| 590 | assert "musehub_sessions" in tables |
| 591 | |
| 592 | |
| 593 | # ══════════════════════════════════════════════════════════════════════════════ |
| 594 | # 6. Security |
| 595 | # ══════════════════════════════════════════════════════════════════════════════ |
| 596 | |
| 597 | |
| 598 | class TestMigrationSecurity: |
| 599 | """Ensure migrations don't introduce security-relevant schema regressions.""" |
| 600 | |
| 601 | def test_legacy_auth_tables_dropped_at_head( |
| 602 | self, migrated_engine: AsyncEngine |
| 603 | ) -> None: |
| 604 | """muse_users, muse_access_tokens, musehub_profiles must not exist at HEAD.""" |
| 605 | tables = asyncio.run(_tables(migrated_engine)) |
| 606 | for legacy in _LEGACY_TABLES: |
| 607 | assert legacy not in tables, f"Legacy auth table '{legacy}' still present at HEAD" |
| 608 | |
| 609 | def test_musehub_auth_keys_has_fingerprint_unique_constraint( |
| 610 | self, migrated_engine: AsyncEngine |
| 611 | ) -> None: |
| 612 | """Fingerprint uniqueness prevents duplicate key registration.""" |
| 613 | indexes = asyncio.run( |
| 614 | _indexes_for(migrated_engine, "musehub_auth_keys") |
| 615 | ) |
| 616 | assert "uq_musehub_auth_keys_fingerprint" in indexes |
| 617 | |
| 618 | def test_musehub_identities_handle_unique( |
| 619 | self, migrated_engine: AsyncEngine |
| 620 | ) -> None: |
| 621 | """Identity handles must be unique — prevents impersonation via duplicate handle.""" |
| 622 | indexes = asyncio.run( |
| 623 | _indexes_for(migrated_engine, "musehub_identities") |
| 624 | ) |
| 625 | assert "uq_musehub_identities_handle" in indexes |
| 626 | |
| 627 | def test_downgrade_does_not_expose_dropped_columns(self) -> None: |
| 628 | """After 0019 downgrade and re-upgrade, state_refs stays dropped. |
| 629 | |
| 630 | A faulty downgrade might resurrect dropped columns — verify it doesn't. |
| 631 | """ |
| 632 | _downgrade(_TEST_URL, "0018") |
| 633 | _upgrade(_TEST_URL) # Re-apply 0019 |
| 634 | engine = create_async_engine(_TEST_URL, poolclass=NullPool) |
| 635 | try: |
| 636 | cols = asyncio.run( |
| 637 | _columns(engine, "musehub_issue_comments") |
| 638 | ) |
| 639 | finally: |
| 640 | asyncio.run(engine.dispose()) |
| 641 | assert "state_refs" not in cols |
| 642 | |
| 643 | def test_repos_owner_slug_uniqueness_enforced( |
| 644 | self, migrated_engine: AsyncEngine |
| 645 | ) -> None: |
| 646 | """Inserting two repos with the same owner/slug must fail with an integrity error.""" |
| 647 | from sqlalchemy.exc import IntegrityError |
| 648 | |
| 649 | async def _run() -> None: |
| 650 | rid1 = str(uuid.uuid4()) |
| 651 | rid2 = str(uuid.uuid4()) |
| 652 | async with migrated_engine.connect() as conn: |
| 653 | await conn.execute( |
| 654 | text( |
| 655 | "INSERT INTO musehub_repos (repo_id, name, slug, owner, owner_user_id, visibility, default_branch) " |
| 656 | "VALUES (:id, 'dup', 'dup-slug', 'sec-owner', 'sec-owner', 'public', 'main')" |
| 657 | ), |
| 658 | {"id": rid1}, |
| 659 | ) |
| 660 | await conn.commit() |
| 661 | async with migrated_engine.connect() as conn: |
| 662 | with pytest.raises(IntegrityError): |
| 663 | await conn.execute( |
| 664 | text( |
| 665 | "INSERT INTO musehub_repos (repo_id, name, slug, owner, owner_user_id, visibility, default_branch) " |
| 666 | "VALUES (:id, 'dup', 'dup-slug', 'sec-owner', 'sec-owner', 'public', 'main')" |
| 667 | ), |
| 668 | {"id": rid2}, |
| 669 | ) |
| 670 | await conn.commit() |
| 671 | |
| 672 | asyncio.run(_run()) |
| 673 | |
| 674 | |
| 675 | # ══════════════════════════════════════════════════════════════════════════════ |
| 676 | # 7. Performance |
| 677 | # ══════════════════════════════════════════════════════════════════════════════ |
| 678 | |
| 679 | |
| 680 | class TestMigrationPerformance: |
| 681 | """Latency budgets for migration operations.""" |
| 682 | |
| 683 | def test_single_step_upgrade_under_5_seconds(self) -> None: |
| 684 | """Each individual migration step must complete in under 5 seconds.""" |
| 685 | _downgrade(_TEST_URL, "base") |
| 686 | for rev in _ALL_REVISIONS: |
| 687 | start = time.perf_counter() |
| 688 | _upgrade(_TEST_URL, rev) |
| 689 | elapsed = time.perf_counter() - start |
| 690 | assert elapsed < 5, ( |
| 691 | f"Migration {rev} upgrade took {elapsed:.2f}s (budget: 5s per step)" |
| 692 | ) |
| 693 | |
| 694 | def test_single_step_downgrade_under_5_seconds(self) -> None: |
| 695 | """Each individual migration downgrade must complete in under 5 seconds.""" |
| 696 | # Currently at HEAD |
| 697 | for rev in reversed(_ALL_REVISIONS[:-1]): # downgrade from 0019 to 0001 |
| 698 | start = time.perf_counter() |
| 699 | _downgrade(_TEST_URL, rev) |
| 700 | elapsed = time.perf_counter() - start |
| 701 | assert elapsed < 5, ( |
| 702 | f"Migration {rev} downgrade took {elapsed:.2f}s (budget: 5s per step)" |
| 703 | ) |
| 704 | # Downgrade 0001 to base |
| 705 | start = time.perf_counter() |
| 706 | _downgrade(_TEST_URL, "base") |
| 707 | elapsed = time.perf_counter() - start |
| 708 | assert elapsed < 5, f"Migration 0001 downgrade took {elapsed:.2f}s" |
| 709 | # Restore HEAD for any remaining tests |
| 710 | _upgrade(_TEST_URL) |
| 711 | |
| 712 | def test_schema_introspection_under_500ms( |
| 713 | self, migrated_engine: AsyncEngine |
| 714 | ) -> None: |
| 715 | """Listing all tables in the public schema must complete in under 500ms.""" |
| 716 | start = time.perf_counter() |
| 717 | asyncio.run(_tables(migrated_engine)) |
| 718 | elapsed_ms = (time.perf_counter() - start) * 1000 |
| 719 | assert elapsed_ms < 500, f"Table introspection took {elapsed_ms:.0f}ms (budget: 500ms)" |
| 720 | |
| 721 | def test_index_introspection_under_200ms( |
| 722 | self, migrated_engine: AsyncEngine |
| 723 | ) -> None: |
| 724 | """Listing indexes for a single table must complete in under 200ms.""" |
| 725 | start = time.perf_counter() |
| 726 | asyncio.run( |
| 727 | _indexes_for(migrated_engine, "musehub_repos") |
| 728 | ) |
| 729 | elapsed_ms = (time.perf_counter() - start) * 1000 |
| 730 | assert elapsed_ms < 200, f"Index introspection took {elapsed_ms:.0f}ms (budget: 200ms)" |
File History
1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a
init: musehub initial commit
Human
171 days ago