gabriel / musehub public
conftest.py python
883 lines 34.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago
1 """Pytest configuration and fixtures."""
2 from __future__ import annotations
3
4 from pathlib import Path
5
6 import logging
7 import os
8 import typing
9 from collections.abc import AsyncGenerator, Generator
10
11 if not os.environ.get("MUSE_ENV"):
12 os.environ["MUSE_ENV"] = "test"
13
14 import pytest
15 import pytest_asyncio
16 from httpx import AsyncClient, ASGITransport
17 from sqlalchemy.ext.asyncio import (
18 AsyncSession,
19 async_sessionmaker,
20 create_async_engine,
21 )
22 from sqlalchemy.pool import NullPool
23
24 from musehub.core.genesis import compute_identity_id
25 from musehub.db import database
26 from musehub.db.database import Base, get_db
27 from musehub.db.musehub_models import MusehubIdentity
28 from musehub.types.json_types import JSONValue
29 # Force all ORM models into Base.metadata before any create_all/drop_all.
30 # muse_cli_models is only imported inside init_db() in production; without
31 # this explicit import, Base.metadata is non-deterministic in tests (depends
32 # on import order), causing drop_all to miss tables that create_all later
33 # tries to create — resulting in duplicate-key errors on pg_type.
34 import musehub.db.muse_cli_models as _muse_cli_models # noqa: F401
35 from musehub.auth.request_signing import MSignContext, optional_signed_request, require_signed_request
36 from musehub.main import app
37 from musehub.rate_limits import limiter
38
39 type _JobPayload = dict[str, str | int | bool | None]
40 import musehub.auth.failure_limiter as _failure_limiter
41
42
43 @pytest.fixture(autouse=True)
44 def _stub_push_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
45 """Replace enqueue_push_intel with a no-op spy during tests.
46
47 The push endpoint enqueues intel jobs into the DB. During tests we don't
48 want a live worker processing those jobs concurrently. This fixture
49 replaces enqueue_push_intel with a no-op that records calls in a
50 module-level list so integration tests can assert on what was enqueued
51 without touching the DB.
52 """
53 import musehub.services.musehub_jobs as _jobs
54
55 _jobs._test_enqueued_calls.clear()
56
57 async def _spy_enqueue(
58 session: AsyncSession, repo_id: str, head: str, domain_id: str | None = None, branch: str = ""
59 ) -> None:
60 _jobs._test_enqueued_calls.append((repo_id, "enqueue_push_intel", {"head": head, "domain_id": domain_id, "branch": branch}))
61
62 monkeypatch.setattr(_jobs, "enqueue_push_intel", _spy_enqueue)
63
64
65 @pytest.fixture(autouse=True)
66 def _tmp_objects_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
67 """Redirect object storage to a per-test temp directory.
68
69 Prevents tests from writing to the real storage path and isolates
70 object state between tests. autouse=True so every test gets a
71 fresh, empty object store without needing to request the fixture.
72 """
73 import musehub.storage.backends as _backends
74 import musehub.services.musehub_wire as _wire_svc
75 import musehub.api.routes.wire as _wire_route
76 from musehub.config import settings
77
78 test_backend = _backends.LocalBackend(repo_root=tmp_path)
79 monkeypatch.setattr(_wire_svc, "get_backend", lambda *_a, **_kw: test_backend)
80 monkeypatch.setattr(_wire_route, "get_backend", lambda *_a, **_kw: test_backend)
81 # Redirect musehub_repos_dir to tmp_path so disk_path containment
82 # checks in get_object_content / get_blob_meta see the right root.
83 monkeypatch.setattr(settings, "musehub_repos_dir", str(tmp_path))
84
85 # Redirect the /releases StaticFiles mount to a temp dir so tests that
86 # hit /releases/* don't fail because /data/releases doesn't exist locally.
87 releases_dir = f"{tmp_path}/releases"
88 os.makedirs(releases_dir, exist_ok=True)
89 from musehub.main import app as _app
90 for _route in _app.routes:
91 if getattr(_route, "name", None) == "releases":
92 _static = _route.app # type: ignore[attr-defined]
93 _static.directory = releases_dir
94 _static.config_checked = False # force re-check with new dir
95 break
96
97
98 def pytest_configure(config: pytest.Config) -> None:
99 """Ensure asyncio_mode is auto so async fixtures work (e.g. in Docker when pyproject not in cwd)."""
100 if hasattr(config.option, "asyncio_mode") and config.option.asyncio_mode is None:
101 config.option.asyncio_mode = "auto"
102 # Suppress verbose library loggers that flood the test output with DEBUG lines.
103 for name in ("httpcore", "httpx", "sqlalchemy", "asyncio", "faker"):
104 logging.getLogger(name).setLevel(logging.WARNING)
105
106
107 @pytest.fixture(autouse=True)
108 def reset_rate_limiter() -> Generator[None, None, None]:
109 """Reset in-memory rate-limit counters before every test.
110
111 Without this, the shared MemoryStorage accumulates hits across all tests
112 in a session. Auth endpoints cap at 20/minute; running 30+ auth tests
113 back-to-back exhausts that budget and causes 429s for legitimate calls.
114 """
115 limiter.reset()
116 _failure_limiter._failures.clear()
117 yield
118
119
120 @pytest.fixture
121 def anyio_backend() -> str:
122 return "asyncio"
123
124
125 _WIRE_CONTEXT = MSignContext(
126 handle="test-user-wire",
127 identity_id="wire-test-user-id",
128 is_agent=False,
129 is_admin=False,
130 )
131
132
133 @pytest.fixture
134 def wire_headers() -> Generator[dict[str, str], None, None]:
135 """Override auth deps to inject a fake MSignContext for wire protocol tests."""
136 app.dependency_overrides[require_signed_request] = lambda: _WIRE_CONTEXT
137 app.dependency_overrides[optional_signed_request] = lambda: _WIRE_CONTEXT
138 yield {
139 "Content-Type": "application/x-msgpack",
140 "Accept": "application/x-msgpack",
141 }
142 app.dependency_overrides.pop(require_signed_request, None)
143 app.dependency_overrides.pop(optional_signed_request, None)
144
145
146 @pytest.fixture(autouse=True)
147 def _reset_variation_store() -> Generator[None, None, None]:
148 """Reset the singleton VariationStore between tests to prevent cross-test pollution.
149
150 Gracefully no-ops if the variation module has been removed (MuseHub extraction).
151 """
152 yield
153 try:
154 from musehub.variation.storage.variation_store import reset_variation_store
155 reset_variation_store()
156 except ModuleNotFoundError:
157 pass
158
159
160 _TEST_DATABASE_URL = os.environ.get(
161 "TEST_DATABASE_URL",
162 "postgresql+asyncpg://musehub:musehub@localhost:5434/musehub_test",
163 )
164
165 # Sync URL for psycopg2 — used by the session-scoped schema fixture.
166 _TEST_DATABASE_URL_SYNC = _TEST_DATABASE_URL.replace("+asyncpg", "")
167
168 # Shared async engine for the whole test session (NullPool = no connection
169 # reuse between tests, but engine object creation is cheap so we create it
170 # once and share it).
171 _TEST_ENGINE = create_async_engine(_TEST_DATABASE_URL, poolclass=NullPool)
172 _TEST_SESSION_FACTORY = async_sessionmaker(
173 bind=_TEST_ENGINE,
174 class_=AsyncSession,
175 expire_on_commit=False,
176 )
177
178 # Pre-compute the TRUNCATE statement for all tables so we don't rebuild it
179 # each test. Reversed sorted_tables respects FK dependency order.
180 _TRUNCATE_SQL = "TRUNCATE {} RESTART IDENTITY CASCADE".format(
181 ", ".join(t.name for t in reversed(Base.metadata.sorted_tables))
182 )
183
184
185 @pytest.fixture(scope="session", autouse=True)
186 def _db_schema() -> Generator[None, None, None]:
187 """Create the test schema once per test session using a sync psycopg2 engine.
188
189 This replaces per-test drop_all/create_all (which took ~3 s per test on
190 PostgreSQL) with a single DDL pass at session start and end. Individual
191 tests get a clean slate via TRUNCATE in the db_session fixture instead.
192 """
193 from sqlalchemy import create_engine as _create_engine
194
195 from sqlalchemy import text as _text
196
197 # connect_timeout=10: if postgres is unreachable or still starting (e.g.
198 # Docker container not ready), fail fast instead of blocking in C forever.
199 # Without this, Ctrl+C cannot kill the process because psycopg2's socket
200 # read is a non-interruptible C-level call.
201 sync_engine = _create_engine(
202 _TEST_DATABASE_URL_SYNC,
203 connect_args={"connect_timeout": 10},
204 )
205 # Terminate any leftover connections from interrupted test runs before
206 # running drop_all. If a previous pytest session was killed with SIGQUIT
207 # (Ctrl+\) it leaves postgres backends idle-in-transaction holding locks on
208 # the test tables. drop_all then waits forever for those locks, which
209 # makes the next test run freeze with Ctrl+C unresponsive.
210 with sync_engine.connect() as _conn:
211 _conn.execute(_text(
212 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
213 "WHERE datname = current_database() AND pid != pg_backend_pid()"
214 ))
215 _conn.commit()
216 # Dispose so drop_all / create_all get fresh connections — the
217 # pg_terminate_backend above may have killed pooled connections.
218 sync_engine.dispose()
219 sync_engine2 = _create_engine(
220 _TEST_DATABASE_URL_SYNC,
221 connect_args={"connect_timeout": 10},
222 )
223 Base.metadata.drop_all(sync_engine2)
224 sync_engine2.dispose()
225 sync_engine2 = _create_engine(
226 _TEST_DATABASE_URL_SYNC,
227 connect_args={"connect_timeout": 10},
228 )
229 Base.metadata.create_all(sync_engine2)
230 # Seed claim types (mirrors alembic/versions/0043 seed logic)
231 from musehub.services.musehub_attestations import _CLAIM_TYPES
232 with sync_engine2.connect() as _conn:
233 for ct in _CLAIM_TYPES.values():
234 _conn.execute(_text(
235 "INSERT INTO musehub_attestation_claim_types "
236 "(type_key, category, label, description, valid_scopes, introduced_at) "
237 "VALUES (:key, :cat, :label, :desc, :scopes, NOW()) "
238 "ON CONFLICT (type_key) DO NOTHING"
239 ), {"key": ct["type_key"], "cat": ct["category"], "label": ct["label"],
240 "desc": ct["description"], "scopes": ct["valid_scopes"]})
241 _conn.commit()
242 sync_engine2.dispose()
243 yield
244 sync_engine3 = _create_engine(
245 _TEST_DATABASE_URL_SYNC,
246 connect_args={"connect_timeout": 10},
247 )
248 with sync_engine3.connect() as _conn:
249 _conn.execute(_text(
250 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
251 "WHERE datname = current_database() AND pid != pg_backend_pid()"
252 ))
253 _conn.commit()
254 sync_engine3.dispose()
255 sync_engine4 = _create_engine(
256 _TEST_DATABASE_URL_SYNC,
257 connect_args={"connect_timeout": 10},
258 )
259 Base.metadata.drop_all(sync_engine4)
260 sync_engine4.dispose()
261
262
263 @pytest_asyncio.fixture
264 async def db_session(_db_schema: None) -> AsyncGenerator[AsyncSession, None]:
265 """Provide a clean DB session for each test.
266
267 Tables are truncated (not dropped/recreated) between tests — a single
268 TRUNCATE … CASCADE is ~100× faster than drop_all + create_all on
269 PostgreSQL, cutting per-test overhead from ~3 s to ~30 ms.
270 """
271 from sqlalchemy import text as _text
272
273 async with _TEST_ENGINE.begin() as conn:
274 # Terminate ALL other backends before TRUNCATE. A failed test can
275 # leave a connection in any state (idle in transaction, idle in
276 # transaction (aborted), active) — filtering by state misses some
277 # cases and causes deadlocks when TRUNCATE races the stale transaction.
278 await conn.execute(_text(
279 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
280 "WHERE datname = current_database() AND pid != pg_backend_pid()"
281 ))
282 await conn.execute(_text(_TRUNCATE_SQL))
283 # Re-seed reference tables that are wiped by TRUNCATE CASCADE.
284 from musehub.services.musehub_attestations import _CLAIM_TYPES
285 for _ct in _CLAIM_TYPES.values():
286 await conn.execute(_text(
287 "INSERT INTO musehub_attestation_claim_types "
288 "(type_key, category, label, description, valid_scopes, introduced_at) "
289 "VALUES (:key, :cat, :label, :desc, :scopes, NOW()) "
290 "ON CONFLICT (type_key) DO NOTHING"
291 ), {"key": _ct["type_key"], "cat": _ct["category"], "label": _ct["label"],
292 "desc": _ct["description"], "scopes": _ct["valid_scopes"]})
293
294 old_engine = database._engine
295 old_factory = database._async_session_factory
296 database._engine = _TEST_ENGINE
297 database._async_session_factory = _TEST_SESSION_FACTORY
298 try:
299 async with _TEST_SESSION_FACTORY() as session:
300 async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
301 # Each request gets its own session so concurrent requests
302 # (e.g. stress tests) don't share a single connection and
303 # raise "concurrent operations are not permitted".
304 # All test setup data is committed, so independent sessions
305 # see it without needing to share the test session.
306 async with _TEST_SESSION_FACTORY() as req_session:
307 yield req_session
308 app.dependency_overrides[get_db] = override_get_db
309 yield session
310 app.dependency_overrides.clear()
311 finally:
312 database._engine = old_engine
313 database._async_session_factory = old_factory
314
315
316 @pytest_asyncio.fixture
317 async def session_factory(_db_schema: None):
318 """Expose the test session factory for tests needing multiple concurrent sessions."""
319 return _TEST_SESSION_FACTORY
320
321
322 class _Asgi24Wrapper:
323 """Inject spec_version='2.4' into every HTTP scope.
324
325 Without this, Starlette's StreamingResponse (spec_version < 2.4 path) runs
326 listen_for_disconnect concurrently with stream_response via anyio task_group.
327 listen_for_disconnect calls receive() and steals the request body chunks
328 before _AsyncExactReader can read them — causing a deadlock where both tasks
329 block on response_complete.wait() waiting for each other.
330
331 ASGI 2.4 tells Starlette to skip listen_for_disconnect and just stream the
332 response directly, which is correct for our streaming push handler.
333 """
334
335 def __init__(self, app: typing.Any) -> None:
336 self._app = app
337
338 async def __call__(self, scope: typing.MutableMapping[str, typing.Any], receive: typing.Any, send: typing.Any) -> None:
339 if scope.get("type") == "http":
340 scope.setdefault("asgi", {})["spec_version"] = "2.4"
341 await self._app(scope, receive, send)
342
343
344 @pytest_asyncio.fixture
345 async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
346 """Create an async test client. Depends on db_session so auth revocation check uses test DB."""
347 transport = ASGITransport(app=_Asgi24Wrapper(app))
348 async with AsyncClient(transport=transport, base_url="http://test") as ac:
349 yield ac
350
351
352 # -----------------------------------------------------------------------------
353 # Auth fixtures for API contract and integration tests
354 # Uses dependency_overrides to inject a fake MSignContext so tests don't need
355 # real Ed25519 key pairs. Only active for tests that request auth_headers.
356 # -----------------------------------------------------------------------------
357
358 _TEST_IDENTITY_ID = compute_identity_id(b"testuser")
359 _TEST_HANDLE = "testuser"
360
361 _TEST_CONTEXT = MSignContext(
362 handle=_TEST_HANDLE,
363 identity_id=_TEST_IDENTITY_ID,
364 is_agent=False,
365 is_admin=False,
366 )
367
368
369 @pytest_asyncio.fixture
370 async def test_user(db_session: AsyncSession) -> MusehubIdentity:
371 """Create a test identity in the DB for authenticated route tests."""
372 identity = MusehubIdentity(
373 identity_id=_TEST_IDENTITY_ID,
374 handle=_TEST_HANDLE,
375 display_name="Test User",
376 identity_type="human",
377 )
378 db_session.add(identity)
379 await db_session.commit()
380 await db_session.refresh(identity)
381 # Close the autobegin transaction started by refresh() so subsequent
382 # test-body commits don't hit "another operation is in progress".
383 await db_session.commit()
384 return identity
385
386
387 @pytest.fixture
388 def auth_headers(test_user: MusehubIdentity) -> Generator[dict[str, str], None, None]:
389 """Override auth dependencies to inject a fake MSignContext for the test duration.
390
391 Tests that need to verify 401 behaviour for *unauthenticated* requests should
392 use a separate client call without passing ``auth_headers`` — note that while
393 this fixture is active the app-level dep overrides are set globally, so any
394 request made within the same test function will be treated as authenticated.
395 Tests that need to distinguish authed/unauthed flows within one function should
396 use ``app.dependency_overrides`` directly or split into two test functions.
397 """
398 app.dependency_overrides[require_signed_request] = lambda: _TEST_CONTEXT
399 app.dependency_overrides[optional_signed_request] = lambda: _TEST_CONTEXT
400 yield {"Content-Type": "application/json"}
401 app.dependency_overrides.pop(require_signed_request, None)
402 app.dependency_overrides.pop(optional_signed_request, None)
403
404
405 # ---------------------------------------------------------------------------
406 # Symbol-detail fixtures
407 # Used by test_symbol_detail_phase1.py (T2–T7 tiers).
408 # ---------------------------------------------------------------------------
409
410 import datetime as _dt
411 import contextlib as _contextlib
412 import time as _time
413 from muse.core.types import blob_id
414
415
416 def _utc_now() -> _dt.datetime:
417 return _dt.datetime.now(tz=_dt.timezone.utc)
418
419
420 async def _make_repo_row(session: AsyncSession, owner: str, slug: str) -> "MusehubRepo":
421 from musehub.db.musehub_models import MusehubRepo
422 from musehub.core.genesis import compute_identity_id, compute_repo_id
423 owner_user_id = compute_identity_id(owner.encode())
424 created_at = _utc_now()
425 repo_id = compute_repo_id(owner_user_id, slug, "code", created_at.isoformat())
426 repo = MusehubRepo(
427 repo_id=repo_id,
428 name=slug,
429 owner=owner,
430 slug=slug,
431 visibility="public",
432 owner_user_id=owner_user_id,
433 description="",
434 tags=[],
435 created_at=created_at,
436 )
437 session.add(repo)
438 await session.commit()
439 return repo
440
441
442 async def _make_commit_row(session: AsyncSession, repo_id: str, commit_id: str, **kwargs: JSONValue) -> None:
443 from musehub.db.musehub_models import MusehubCommit
444 defaults = dict(
445 commit_id=commit_id,
446 repo_id=repo_id,
447 branch="dev",
448 parent_ids=[],
449 message="feat: test",
450 author="gabriel",
451 timestamp=_utc_now(),
452 snapshot_id=blob_id(f"snap-{commit_id}".encode()),
453 agent_id="claude-code",
454 model_id="claude-sonnet-4-6",
455 commit_branch="task/test",
456 signature="",
457 )
458 defaults.update(kwargs)
459 session.add(MusehubCommit(**defaults))
460 await session.commit()
461
462
463 async def _make_history_entry(
464 session: AsyncSession, repo_id: str, address: str, commit_id: str,
465 op: str = "add", content_id: str | None = None,
466 committed_at: _dt.datetime | None = None,
467 ) -> None:
468 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
469 session.add(MusehubSymbolHistoryEntry(
470 repo_id=repo_id,
471 address=address,
472 commit_id=commit_id,
473 committed_at=committed_at or _utc_now(),
474 author="gabriel",
475 op=op,
476 content_id=content_id or blob_id(f"body-{address}-{commit_id}".encode()),
477 ))
478 await session.commit()
479
480
481 @pytest_asyncio.fixture
482 async def repo_fixture(db_session: AsyncSession) -> tuple[str, str]:
483 """Create a bare repo with no symbol history. Returns (owner, slug)."""
484 repo = await _make_repo_row(db_session, "gabriel", "test-repo")
485 return ("gabriel", repo.slug)
486
487
488 @pytest_asyncio.fixture
489 async def seed_symbol(db_session: AsyncSession) -> tuple[str, str, str]:
490 """Create a repo with one symbol history entry and a commit. Returns (owner, slug, address)."""
491 owner, slug = "gabriel", "seed-repo"
492 address = "src/core.py::compute"
493 repo = await _make_repo_row(db_session, owner, slug)
494 commit_id = blob_id(f"commit-seed-{slug}".encode())
495 await _make_commit_row(db_session, repo.repo_id, commit_id)
496 await _make_history_entry(db_session, repo.repo_id, address, commit_id)
497 return (owner, slug, address)
498
499
500 @pytest_asyncio.fixture
501 async def seed_type_intel(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
502 """Add a MusehubIntelType row for the seeded symbol."""
503 from musehub.db.musehub_models import MusehubIntelType, MusehubRepo
504 from sqlalchemy import select
505 owner, slug, address = seed_symbol
506 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
507 repo = result.scalar_one()
508 db_session.add(MusehubIntelType(
509 repo_id=repo.repo_id,
510 address=address,
511 kind="function",
512 return_is_any=False,
513 params_total=2,
514 params_annotated=2,
515 params_with_any=0,
516 type_score=0.95,
517 ref="dev",
518 ))
519 await db_session.commit()
520
521
522 @pytest_asyncio.fixture
523 async def seed_sym_intel(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
524 """Add a MusehubSymbolIntel row for the seeded symbol."""
525 from musehub.db.musehub_models import MusehubSymbolIntel, MusehubRepo
526 from sqlalchemy import select
527 owner, slug, address = seed_symbol
528 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
529 repo = result.scalar_one()
530 db_session.add(MusehubSymbolIntel(
531 repo_id=repo.repo_id,
532 address=address,
533 churn=5,
534 churn_30d=2,
535 churn_90d=4,
536 blast=3,
537 blast_direct=2,
538 blast_cross=1,
539 blast_top=[],
540 author_count=1,
541 gravity=0.1,
542 weekly=[],
543 gravity_pct=10.0,
544 gravity_direct_dependents=2,
545 gravity_transitive_dependents=3,
546 gravity_max_depth=2,
547 ))
548 await db_session.commit()
549
550
551 @pytest_asyncio.fixture
552 async def seed_api_intel(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
553 """Add a MusehubIntelApiSurface row for the seeded symbol."""
554 from musehub.db.musehub_models import MusehubIntelApiSurface, MusehubRepo
555 from sqlalchemy import select
556 owner, slug, address = seed_symbol
557 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
558 repo = result.scalar_one()
559 db_session.add(MusehubIntelApiSurface(
560 repo_id=repo.repo_id,
561 address=address,
562 kind="function",
563 visibility="public",
564 ref="dev",
565 ))
566 await db_session.commit()
567
568
569 @pytest_asyncio.fixture
570 async def seed_many_refactor_events(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
571 """Add 25 MusehubIntelRefactorEvent rows for the seeded symbol."""
572 from musehub.db.musehub_models import MusehubIntelRefactorEvent, MusehubRepo
573 from sqlalchemy import select
574 owner, slug, address = seed_symbol
575 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
576 repo = result.scalar_one()
577 for i in range(25):
578 db_session.add(MusehubIntelRefactorEvent(
579 event_id=blob_id(f"refactor-{i}-{slug}".encode()),
580 repo_id=repo.repo_id,
581 kind="implementation",
582 address=address,
583 detail=f"refactor event {i}",
584 commit_id=blob_id(f"rc-{i}".encode()),
585 committed_at=_utc_now(),
586 ))
587 await db_session.commit()
588
589
590 @pytest_asyncio.fixture
591 async def seed_refactor_event(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
592 """Add one MusehubIntelRefactorEvent with kind=implementation."""
593 from musehub.db.musehub_models import MusehubIntelRefactorEvent, MusehubRepo
594 from sqlalchemy import select
595 owner, slug, address = seed_symbol
596 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
597 repo = result.scalar_one()
598 db_session.add(MusehubIntelRefactorEvent(
599 event_id=blob_id(f"refactor-single-{slug}".encode()),
600 repo_id=repo.repo_id,
601 kind="implementation",
602 address=address,
603 detail="extracted helper",
604 commit_id=blob_id(f"rc-single-{slug}".encode()),
605 committed_at=_utc_now(),
606 ))
607 await db_session.commit()
608
609
610 @pytest_asyncio.fixture
611 async def seed_refactor_event_with_xss(db_session: AsyncSession, seed_symbol: tuple[str, str, str]) -> None:
612 """Add a refactor event whose detail field contains an XSS payload."""
613 from musehub.db.musehub_models import MusehubIntelRefactorEvent, MusehubRepo
614 from sqlalchemy import select
615 owner, slug, address = seed_symbol
616 result = await db_session.execute(select(MusehubRepo).where(MusehubRepo.owner == owner, MusehubRepo.slug == slug))
617 repo = result.scalar_one()
618 db_session.add(MusehubIntelRefactorEvent(
619 event_id=blob_id(f"refactor-xss-{slug}".encode()),
620 repo_id=repo.repo_id,
621 kind="implementation",
622 address=address,
623 detail='<img src=x onerror=alert(1)>',
624 commit_id=blob_id(f"rc-xss-{slug}".encode()),
625 committed_at=_utc_now(),
626 ))
627 await db_session.commit()
628
629
630 @pytest_asyncio.fixture
631 async def seed_symbol_with_xss_commit(db_session: AsyncSession) -> tuple[str, str, str]:
632 """Create a symbol whose commit message contains an XSS payload."""
633 owner, slug = "gabriel", "xss-repo"
634 address = "src/evil.py::fn"
635 repo = await _make_repo_row(db_session, owner, slug)
636 commit_id = blob_id(f"commit-xss-{slug}".encode())
637 await _make_commit_row(
638 db_session, repo.repo_id, commit_id,
639 message='<img src=x onerror=alert(1)> feat: xss test',
640 )
641 await _make_history_entry(db_session, repo.repo_id, address, commit_id)
642 return (owner, slug, address)
643
644
645 @pytest_asyncio.fixture
646 async def seed_symbol_with_large_history(db_session: AsyncSession) -> tuple[str, str, str]:
647 """Create a symbol with 200 history entries (stress test — not 10k, keeps test fast)."""
648 owner, slug = "gabriel", "large-history-repo"
649 address = "src/big.py::process"
650 repo = await _make_repo_row(db_session, owner, slug)
651 for i in range(200):
652 cid = blob_id(f"commit-large-{i}-{slug}".encode())
653 await _make_commit_row(db_session, repo.repo_id, cid)
654 await _make_history_entry(db_session, repo.repo_id, address, cid, op="modify")
655 return (owner, slug, address)
656
657
658 @pytest_asyncio.fixture
659 async def seed_symbol_high_coupling(db_session: AsyncSession) -> tuple[str, str, str]:
660 """Create a symbol that co-changes with many partners."""
661 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
662 owner, slug = "gabriel", "coupling-repo"
663 address = "src/hub.py::dispatch"
664 repo = await _make_repo_row(db_session, owner, slug)
665 commit_id = blob_id(f"commit-coupling-{slug}".encode())
666 await _make_commit_row(db_session, repo.repo_id, commit_id)
667 await _make_history_entry(db_session, repo.repo_id, address, commit_id)
668 # 25 co-changing partners in the same commit
669 for i in range(25):
670 partner = f"src/partner_{i}.py::fn"
671 db_session.add(MusehubSymbolHistoryEntry(
672 repo_id=repo.repo_id,
673 address=partner,
674 commit_id=commit_id,
675 committed_at=_utc_now(),
676 author="gabriel",
677 op="modify",
678 content_id=blob_id(f"body-partner-{i}".encode()),
679 ))
680 await db_session.commit()
681 return (owner, slug, address)
682
683
684 @pytest_asyncio.fixture
685 async def seed_symbol_with_clones(db_session: AsyncSession) -> tuple[str, str, str]:
686 """Create a symbol with a clone entry."""
687 from musehub.db.musehub_models import MusehubHashOccurrenceEntry
688 owner, slug = "gabriel", "clones-repo"
689 address = "src/original.py::fn"
690 repo = await _make_repo_row(db_session, owner, slug)
691 commit_id = blob_id(f"commit-clone-{slug}".encode())
692 content_id = blob_id(f"shared-body-{slug}".encode())
693 await _make_commit_row(db_session, repo.repo_id, commit_id)
694 await _make_history_entry(db_session, repo.repo_id, address, commit_id, content_id=content_id)
695 # Clone: same content_id, different address
696 db_session.add(MusehubHashOccurrenceEntry(
697 repo_id=repo.repo_id,
698 content_id=content_id,
699 address="src/copy.py::fn",
700 ))
701 db_session.add(MusehubHashOccurrenceEntry(
702 repo_id=repo.repo_id,
703 content_id=content_id,
704 address=address,
705 ))
706 await db_session.commit()
707 return (owner, slug, address)
708
709
710 @pytest.fixture
711 def benchmark_timer():
712 """Context manager that asserts elapsed time stays under max_ms."""
713 @_contextlib.contextmanager
714 def _timer(max_ms: float):
715 start = _time.monotonic()
716 yield
717 elapsed_ms = (_time.monotonic() - start) * 1000
718 assert elapsed_ms < max_ms, f"took {elapsed_ms:.0f}ms, limit {max_ms}ms"
719 return _timer
720
721
722 # ---------------------------------------------------------------------------
723 # Pagination fixtures
724 # Used by test_symbol_detail_pagination.py.
725 # ---------------------------------------------------------------------------
726
727 async def _seed_history_entries(
728 db_session: AsyncSession,
729 owner: str,
730 slug: str,
731 count: int,
732 ) -> tuple[str, str, str]:
733 """Create a repo + symbol with *count* history entries spaced 1 hour apart.
734
735 Commit messages are ``entry-{i}`` for i in 0..count-1.
736 entry-0 is the oldest, entry-(count-1) is the newest.
737 Returns (owner, slug, address).
738 """
739 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
740 address = "src/core.py::paginate_fn"
741 repo = await _make_repo_row(db_session, owner, slug)
742 base_ts = _dt.datetime(2026, 1, 1, 0, 0, 0, tzinfo=_dt.timezone.utc)
743 for i in range(count):
744 committed_at = base_ts + _dt.timedelta(hours=i)
745 commit_id = blob_id(f"commit-hist-{i}-{slug}".encode())
746 await _make_commit_row(
747 db_session, repo.repo_id, commit_id,
748 message=f"entry-{i}",
749 timestamp=committed_at,
750 )
751 await _make_history_entry(
752 db_session, repo.repo_id, address, commit_id,
753 op="modify", committed_at=committed_at,
754 )
755 return (owner, slug, address)
756
757
758 async def _seed_coupling_partners(
759 db_session: AsyncSession,
760 owner: str,
761 slug: str,
762 partner_count: int,
763 ) -> tuple[str, str, str]:
764 """Create a repo + symbol with *partner_count* coupling partners.
765
766 The target symbol appears in all *partner_count* commits.
767 Partner i appears in commits i..(partner_count-1), giving it
768 shared_commits = partner_count - i (descending: partner_0 has the most).
769 Returns (owner, slug, address).
770 """
771 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
772 address = "src/hub.py::dispatch"
773 repo = await _make_repo_row(db_session, owner, slug)
774 base_ts = _dt.datetime(2026, 2, 1, 0, 0, 0, tzinfo=_dt.timezone.utc)
775 for j in range(partner_count):
776 committed_at = base_ts + _dt.timedelta(hours=j)
777 commit_id = blob_id(f"commit-coup-{j}-{slug}".encode())
778 await _make_commit_row(
779 db_session, repo.repo_id, commit_id,
780 message=f"coupling-commit-{j}",
781 timestamp=committed_at,
782 )
783 # Target symbol in every commit
784 db_session.add(MusehubSymbolHistoryEntry(
785 repo_id=repo.repo_id,
786 address=address,
787 commit_id=commit_id,
788 committed_at=committed_at,
789 author="gabriel",
790 op="modify",
791 content_id=blob_id(f"body-target-{j}-{slug}".encode()),
792 ))
793 # Partner i appears in commit j only when i <= j
794 for i in range(j + 1):
795 partner = f"src/partner_{i}.py::fn_{i}"
796 db_session.add(MusehubSymbolHistoryEntry(
797 repo_id=repo.repo_id,
798 address=partner,
799 commit_id=commit_id,
800 committed_at=committed_at,
801 author="gabriel",
802 op="modify",
803 content_id=blob_id(f"body-partner-{i}-{j}-{slug}".encode()),
804 ))
805 await db_session.commit()
806 return (owner, slug, address)
807
808
809 @pytest_asyncio.fixture
810 async def seed_symbol_with_26_history(db_session: AsyncSession) -> tuple[str, str, str]:
811 return await _seed_history_entries(db_session, "gabriel", "hist26-repo", 26)
812
813
814 @pytest_asyncio.fixture
815 async def seed_symbol_with_exactly_10_history(db_session: AsyncSession) -> tuple[str, str, str]:
816 return await _seed_history_entries(db_session, "gabriel", "hist10-repo", 10)
817
818
819 @pytest_asyncio.fixture
820 async def seed_symbol_with_11_history(db_session: AsyncSession) -> tuple[str, str, str]:
821 return await _seed_history_entries(db_session, "gabriel", "hist11-repo", 11)
822
823
824 @pytest_asyncio.fixture
825 async def seed_symbol_high_coupling_40(db_session: AsyncSession) -> tuple[str, str, str]:
826 return await _seed_coupling_partners(db_session, "gabriel", "coup40-repo", 40)
827
828
829 @pytest_asyncio.fixture
830 async def seed_symbol_with_exactly_15_coupling(db_session: AsyncSession) -> tuple[str, str, str]:
831 return await _seed_coupling_partners(db_session, "gabriel", "coup15-repo", 15)
832
833
834 @pytest_asyncio.fixture
835 async def seed_symbol_with_16_coupling(db_session: AsyncSession) -> tuple[str, str, str]:
836 return await _seed_coupling_partners(db_session, "gabriel", "coup16-repo", 16)
837
838
839 @pytest_asyncio.fixture
840 async def seed_symbol_with_26_history_and_40_coupling(
841 db_session: AsyncSession,
842 ) -> tuple[str, str, str]:
843 """26 history entries + 26 coupling partners (from the same commits).
844
845 The target appears in all 26 commits. Partner_i appears in commits i..25,
846 giving shared_commits = 26 - i (descending). This yields 26 partners with
847 positive shared counts → 2 coupling pages (15 + 11) and 3 history pages
848 (10 + 10 + 6). Keeping partners in the history commits avoids inflating the
849 target's change_count with extra coupling-only commits.
850 """
851 from musehub.db.musehub_models import MusehubSymbolHistoryEntry
852 owner, slug = "gabriel", "hist26-coup26-repo"
853 address = "src/core.py::paginate_fn"
854 repo = await _make_repo_row(db_session, owner, slug)
855 base_ts = _dt.datetime(2026, 1, 1, 0, 0, 0, tzinfo=_dt.timezone.utc)
856
857 # 26 commits — target appears in all; partner_i appears in commits i..25.
858 for i in range(26):
859 committed_at = base_ts + _dt.timedelta(hours=i)
860 commit_id = blob_id(f"commit-combo-{i}-{slug}".encode())
861 await _make_commit_row(
862 db_session, repo.repo_id, commit_id,
863 message=f"entry-{i}", timestamp=committed_at,
864 )
865 await _make_history_entry(
866 db_session, repo.repo_id, address, commit_id,
867 op="modify", committed_at=committed_at,
868 )
869 # Every partner whose index <= i is added to this commit.
870 # Partner_j appears in commits j..25 → shared = 26 - j.
871 for j in range(i + 1):
872 partner = f"src/partner_{j}.py::fn_{j}"
873 db_session.add(MusehubSymbolHistoryEntry(
874 repo_id=repo.repo_id,
875 address=partner,
876 commit_id=commit_id,
877 committed_at=committed_at,
878 author="gabriel",
879 op="modify",
880 content_id=blob_id(f"body-combo-partner-{j}-{i}-{slug}".encode()),
881 ))
882 await db_session.commit()
883 return (owner, slug, address)
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago