gabriel / musehub public
conftest.py python
130 lines 4.1 KB
7923a405 test(supercharge): comprehensive test suite overhaul — all 11 points Gabriel Cardona <gabriel@tellurstori.com> 6d ago
1 """Pytest configuration and fixtures."""
2 from __future__ import annotations
3
4 import logging
5 import os
6 from collections.abc import AsyncGenerator, Generator
7
8 # Set before any musehub imports so the Settings lru_cache picks up the value.
9 # This is a test-only secret; in CI/Docker the real secret comes from the environment.
10 os.environ.setdefault("ACCESS_TOKEN_SECRET", "test-secret-for-unit-tests-do-not-use-in-prod")
11
12 import pytest
13 import pytest_asyncio
14 from httpx import AsyncClient, ASGITransport
15 from sqlalchemy.ext.asyncio import (
16 AsyncSession,
17 async_sessionmaker,
18 create_async_engine,
19 )
20 from sqlalchemy.pool import StaticPool
21
22 from musehub.db import database
23 from musehub.db.database import Base, get_db
24 from musehub.db.models import User
25 from musehub.main import app
26
27
28 def pytest_configure(config: pytest.Config) -> None:
29 """Ensure asyncio_mode is auto so async fixtures work (e.g. in Docker when pyproject not in cwd)."""
30 if hasattr(config.option, "asyncio_mode") and config.option.asyncio_mode is None:
31 config.option.asyncio_mode = "auto"
32 logging.getLogger("httpcore").setLevel(logging.CRITICAL)
33
34
35 @pytest.fixture
36 def anyio_backend() -> str:
37 return "asyncio"
38
39
40 @pytest.fixture(autouse=True)
41 def _reset_variation_store() -> Generator[None, None, None]:
42 """Reset the singleton VariationStore between tests to prevent cross-test pollution.
43
44 Gracefully no-ops if the variation module has been removed (MuseHub extraction).
45 """
46 yield
47 try:
48 from musehub.variation.storage.variation_store import reset_variation_store
49 reset_variation_store()
50 except ModuleNotFoundError:
51 pass
52
53
54 @pytest_asyncio.fixture
55 async def db_session() -> AsyncGenerator[AsyncSession, None]:
56 """Create an in-memory test database session."""
57 engine = create_async_engine(
58 "sqlite+aiosqlite:///:memory:",
59 connect_args={"check_same_thread": False},
60 poolclass=StaticPool,
61 )
62 async with engine.begin() as conn:
63 await conn.run_sync(Base.metadata.create_all)
64
65 async_session_factory = async_sessionmaker(
66 bind=engine,
67 class_=AsyncSession,
68 expire_on_commit=False,
69 )
70 old_engine = database._engine
71 old_factory = database._async_session_factory
72 database._engine = engine
73 database._async_session_factory = async_session_factory
74 try:
75 async with async_session_factory() as session:
76 async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
77 yield session
78 app.dependency_overrides[get_db] = override_get_db
79 yield session
80 app.dependency_overrides.clear()
81 finally:
82 database._engine = old_engine
83 database._async_session_factory = old_factory
84 async with engine.begin() as conn:
85 await conn.run_sync(Base.metadata.drop_all)
86 await engine.dispose()
87
88
89 @pytest_asyncio.fixture
90 async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
91
92 """Create an async test client. Depends on db_session so auth revocation check uses test DB."""
93 transport = ASGITransport(app=app)
94 async with AsyncClient(transport=transport, base_url="http://test") as ac:
95 yield ac
96
97
98 # -----------------------------------------------------------------------------
99 # Auth fixtures for API contract and integration tests
100 # -----------------------------------------------------------------------------
101
102 @pytest_asyncio.fixture
103 async def test_user(db_session: AsyncSession) -> User:
104
105 """Create a test user (for authenticated route tests)."""
106 user = User(
107 id="550e8400-e29b-41d4-a716-446655440000",
108 )
109 db_session.add(user)
110 await db_session.commit()
111 await db_session.refresh(user)
112 return user
113
114
115 @pytest.fixture
116 def auth_token(test_user: User) -> str:
117
118 """JWT for test_user (1 hour)."""
119 from musehub.auth.tokens import create_access_token
120 return create_access_token(user_id=test_user.id, expires_hours=1)
121
122
123 @pytest.fixture
124 def auth_headers(auth_token: str) -> dict[str, str]:
125
126 """Headers with Bearer token and JSON content type."""
127 return {
128 "Authorization": f"Bearer {auth_token}",
129 "Content-Type": "application/json",
130 }