gabriel / musehub public
conftest.py python
282 lines 10.9 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 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 from collections.abc import AsyncGenerator, Generator
9
10 if not os.environ.get("MUSE_ENV"):
11 os.environ["MUSE_ENV"] = "test"
12
13 import pytest
14 import pytest_asyncio
15 from httpx import AsyncClient, ASGITransport
16 from sqlalchemy.ext.asyncio import (
17 AsyncSession,
18 async_sessionmaker,
19 create_async_engine,
20 )
21 from sqlalchemy.pool import NullPool
22
23 from musehub.db import database
24 from musehub.db.database import Base, get_db
25 from musehub.db.musehub_models import MusehubIdentity
26 # Force all ORM models into Base.metadata before any create_all/drop_all.
27 # muse_cli_models is only imported inside init_db() in production; without
28 # this explicit import, Base.metadata is non-deterministic in tests (depends
29 # on import order), causing drop_all to miss tables that create_all later
30 # tries to create — resulting in duplicate-key errors on pg_type.
31 import musehub.db.muse_cli_models as _muse_cli_models # noqa: F401
32 from musehub.auth.request_signing import MSignContext, optional_signed_request, require_signed_request
33 from musehub.main import app
34 from musehub.rate_limits import limiter
35 import musehub.auth.failure_limiter as _failure_limiter
36
37
38 @pytest.fixture(autouse=True)
39 def _stub_push_background_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
40 """Replace push-endpoint background tasks with no-ops during tests.
41
42 _build_symbol_index_async and _run_gc_async call AsyncSessionLocal() from
43 within background tasks. Concurrent background sessions during tight push
44 loops (e.g. test_20_repo_fan_out) can interfere with the test session.
45
46 Services under test (GC, symbol indexer) have their own test files that
47 call the service layer directly — they are not affected by this stub.
48 """
49 import musehub.api.routes.wire as _wire
50
51 async def _noop(*args: str | int | None, **kwargs: str | int | None) -> None:
52 pass
53
54 monkeypatch.setattr(_wire, "_build_symbol_index_async", _noop)
55 monkeypatch.setattr(_wire, "_run_gc_async", _noop)
56
57
58 @pytest.fixture(autouse=True)
59 def _tmp_objects_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
60 """Redirect object storage to a per-test temp directory.
61
62 Prevents tests from writing to the real storage path and isolates
63 object state between tests. autouse=True so every test gets a
64 fresh, empty object store without needing to request the fixture.
65 """
66 import musehub.storage.backends as _backends
67 import musehub.services.musehub_wire as _wire_svc
68 import musehub.api.routes.wire as _wire_route
69 from musehub.config import settings
70
71 obj_dir = str(tmp_path) + "/objects"
72 os.makedirs(obj_dir, exist_ok=True)
73 test_backend = _backends.LocalBackend(objects_dir=obj_dir)
74 monkeypatch.setattr(_wire_svc, "get_backend", lambda: test_backend)
75 monkeypatch.setattr(_wire_route, "get_backend", lambda: test_backend)
76 # Keep settings.musehub_objects_dir in sync so disk_path containment
77 # checks in get_object_content / get_blob_meta see the same root.
78 monkeypatch.setattr(settings, "musehub_objects_dir", obj_dir)
79
80
81 def pytest_configure(config: pytest.Config) -> None:
82 """Ensure asyncio_mode is auto so async fixtures work (e.g. in Docker when pyproject not in cwd)."""
83 if hasattr(config.option, "asyncio_mode") and config.option.asyncio_mode is None:
84 config.option.asyncio_mode = "auto"
85 # Suppress verbose library loggers that flood the test output with DEBUG lines.
86 for name in ("httpcore", "httpx", "sqlalchemy", "asyncio", "faker"):
87 logging.getLogger(name).setLevel(logging.WARNING)
88
89
90 @pytest.fixture(autouse=True)
91 def reset_rate_limiter() -> Generator[None, None, None]:
92 """Reset in-memory rate-limit counters before every test.
93
94 Without this, the shared MemoryStorage accumulates hits across all tests
95 in a session. Auth endpoints cap at 20/minute; running 30+ auth tests
96 back-to-back exhausts that budget and causes 429s for legitimate calls.
97 """
98 limiter.reset()
99 _failure_limiter._failures.clear()
100 yield
101
102
103 @pytest.fixture
104 def anyio_backend() -> str:
105 return "asyncio"
106
107
108 _WIRE_CONTEXT = MSignContext(
109 handle="test-user-wire",
110 identity_id="wire-test-user-id",
111 is_agent=False,
112 is_admin=False,
113 )
114
115
116 @pytest.fixture
117 def wire_headers() -> Generator[dict[str, str], None, None]:
118 """Override auth deps to inject a fake MSignContext for wire protocol tests."""
119 app.dependency_overrides[require_signed_request] = lambda: _WIRE_CONTEXT
120 app.dependency_overrides[optional_signed_request] = lambda: _WIRE_CONTEXT
121 yield {
122 "Content-Type": "application/x-msgpack",
123 "Accept": "application/x-msgpack",
124 }
125 app.dependency_overrides.pop(require_signed_request, None)
126 app.dependency_overrides.pop(optional_signed_request, None)
127
128
129 @pytest.fixture(autouse=True)
130 def _reset_variation_store() -> Generator[None, None, None]:
131 """Reset the singleton VariationStore between tests to prevent cross-test pollution.
132
133 Gracefully no-ops if the variation module has been removed (MuseHub extraction).
134 """
135 yield
136 try:
137 from musehub.variation.storage.variation_store import reset_variation_store
138 reset_variation_store()
139 except ModuleNotFoundError:
140 pass
141
142
143 _TEST_DATABASE_URL = os.environ.get(
144 "TEST_DATABASE_URL",
145 "postgresql+asyncpg://musehub:musehub@localhost:5434/musehub_test",
146 )
147
148 # Sync URL for psycopg2 — used by the session-scoped schema fixture.
149 _TEST_DATABASE_URL_SYNC = _TEST_DATABASE_URL.replace("+asyncpg", "")
150
151 # Shared async engine for the whole test session (NullPool = no connection
152 # reuse between tests, but engine object creation is cheap so we create it
153 # once and share it).
154 _TEST_ENGINE = create_async_engine(_TEST_DATABASE_URL, poolclass=NullPool)
155 _TEST_SESSION_FACTORY = async_sessionmaker(
156 bind=_TEST_ENGINE,
157 class_=AsyncSession,
158 expire_on_commit=False,
159 )
160
161 # Pre-compute the TRUNCATE statement for all tables so we don't rebuild it
162 # each test. Reversed sorted_tables respects FK dependency order.
163 _TRUNCATE_SQL = "TRUNCATE {} RESTART IDENTITY CASCADE".format(
164 ", ".join(t.name for t in reversed(Base.metadata.sorted_tables))
165 )
166
167
168 @pytest.fixture(scope="session", autouse=True)
169 def _db_schema() -> Generator[None, None, None]:
170 """Create the test schema once per test session using a sync psycopg2 engine.
171
172 This replaces per-test drop_all/create_all (which took ~3 s per test on
173 PostgreSQL) with a single DDL pass at session start and end. Individual
174 tests get a clean slate via TRUNCATE in the db_session fixture instead.
175 """
176 from sqlalchemy import create_engine as _create_engine
177
178 sync_engine = _create_engine(_TEST_DATABASE_URL_SYNC)
179 Base.metadata.drop_all(sync_engine)
180 Base.metadata.create_all(sync_engine)
181 sync_engine.dispose()
182 yield
183 sync_engine2 = _create_engine(_TEST_DATABASE_URL_SYNC)
184 Base.metadata.drop_all(sync_engine2)
185 sync_engine2.dispose()
186
187
188 @pytest_asyncio.fixture
189 async def db_session(_db_schema: None) -> AsyncGenerator[AsyncSession, None]:
190 """Provide a clean DB session for each test.
191
192 Tables are truncated (not dropped/recreated) between tests — a single
193 TRUNCATE … CASCADE is ~100× faster than drop_all + create_all on
194 PostgreSQL, cutting per-test overhead from ~3 s to ~30 ms.
195 """
196 from sqlalchemy import text as _text
197
198 async with _TEST_ENGINE.begin() as conn:
199 await conn.execute(_text(_TRUNCATE_SQL))
200
201 old_engine = database._engine
202 old_factory = database._async_session_factory
203 database._engine = _TEST_ENGINE
204 database._async_session_factory = _TEST_SESSION_FACTORY
205 try:
206 async with _TEST_SESSION_FACTORY() as session:
207 async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
208 # Each request gets its own session so concurrent requests
209 # (e.g. stress tests) don't share a single connection and
210 # raise "concurrent operations are not permitted".
211 # All test setup data is committed, so independent sessions
212 # see it without needing to share the test session.
213 async with _TEST_SESSION_FACTORY() as req_session:
214 yield req_session
215 app.dependency_overrides[get_db] = override_get_db
216 yield session
217 app.dependency_overrides.clear()
218 finally:
219 database._engine = old_engine
220 database._async_session_factory = old_factory
221
222
223 @pytest_asyncio.fixture
224 async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
225
226 """Create an async test client. Depends on db_session so auth revocation check uses test DB."""
227 transport = ASGITransport(app=app)
228 async with AsyncClient(transport=transport, base_url="http://test") as ac:
229 yield ac
230
231
232 # -----------------------------------------------------------------------------
233 # Auth fixtures for API contract and integration tests
234 # Uses dependency_overrides to inject a fake MSignContext so tests don't need
235 # real Ed25519 key pairs. Only active for tests that request auth_headers.
236 # -----------------------------------------------------------------------------
237
238 _TEST_IDENTITY_ID = "550e8400-e29b-41d4-a716-446655440000"
239 _TEST_HANDLE = "testuser"
240
241 _TEST_CONTEXT = MSignContext(
242 handle=_TEST_HANDLE,
243 identity_id=_TEST_IDENTITY_ID,
244 is_agent=False,
245 is_admin=False,
246 )
247
248
249 @pytest_asyncio.fixture
250 async def test_user(db_session: AsyncSession) -> MusehubIdentity:
251 """Create a test identity in the DB for authenticated route tests."""
252 identity = MusehubIdentity(
253 id=_TEST_IDENTITY_ID,
254 handle=_TEST_HANDLE,
255 display_name="Test User",
256 identity_type="human",
257 )
258 db_session.add(identity)
259 await db_session.commit()
260 await db_session.refresh(identity)
261 # Close the autobegin transaction started by refresh() so subsequent
262 # test-body commits don't hit "another operation is in progress".
263 await db_session.commit()
264 return identity
265
266
267 @pytest.fixture
268 def auth_headers(test_user: MusehubIdentity) -> Generator[dict[str, str], None, None]:
269 """Override auth dependencies to inject a fake MSignContext for the test duration.
270
271 Tests that need to verify 401 behaviour for *unauthenticated* requests should
272 use a separate client call without passing ``auth_headers`` — note that while
273 this fixture is active the app-level dep overrides are set globally, so any
274 request made within the same test function will be treated as authenticated.
275 Tests that need to distinguish authed/unauthed flows within one function should
276 use ``app.dependency_overrides`` directly or split into two test functions.
277 """
278 app.dependency_overrides[require_signed_request] = lambda: _TEST_CONTEXT
279 app.dependency_overrides[optional_signed_request] = lambda: _TEST_CONTEXT
280 yield {"Content-Type": "application/json"}
281 app.dependency_overrides.pop(require_signed_request, None)
282 app.dependency_overrides.pop(optional_signed_request, None)
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago