gabriel / musehub public
test_migrations_section36.py python
730 lines 30.4 KB
Raw
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