""" Test configuration and shared fixtures. Uses an in-memory SQLite async database to avoid needing PostgreSQL in CI. The get_db dependency is overridden so all tests use the test database. """ import pytest import pytest_asyncio from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from typing import AsyncGenerator from app.main import app from app.database import get_db from app.models.base import Base from app.models import User, Page, BlogPost, SiteSettings, RefreshToken, Experiment, ExperimentVariant, ExperimentEvent # noqa: F401 register models from app.models import Member, MemberVerificationCode, MemberRefreshToken, Walk, Booking, AdminMessage # noqa: F401 register member models from app.models import ContactLead # noqa: F401 register contact lead model from app.auth.password import hash_password from app.services.experiments import sync_experiment_registry # pytest-asyncio settings pytest_plugins = ["pytest_asyncio"] # In-memory SQLite for tests TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" test_engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, echo=False, ) TestSessionLocal = async_sessionmaker( bind=test_engine, class_=AsyncSession, expire_on_commit=False, autoflush=False, autocommit=False, ) async def override_get_db() -> AsyncGenerator[AsyncSession, None]: async with TestSessionLocal() as session: try: yield session await session.commit() except Exception: await session.rollback() raise finally: await session.close() @pytest_asyncio.fixture(scope="session", autouse=True) async def setup_database(): """Create all tables once per test session.""" async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with TestSessionLocal() as session: await sync_experiment_registry(session) await session.commit() yield async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await test_engine.dispose() @pytest_asyncio.fixture(autouse=True) async def clean_tables(): """Truncate tables between tests for isolation.""" yield async with test_engine.begin() as conn: for table in reversed(Base.metadata.sorted_tables): await conn.execute(table.delete()) async with TestSessionLocal() as session: await sync_experiment_registry(session) await session.commit() @pytest.fixture(autouse=True) def reset_rate_limiter(): """Reset slowapi's in-memory rate-limit counters before each test. Without this, rapid sequential test runs exhaust the per-IP limits (e.g. 5/min on /auth/login) and cause cascading 429 errors that mask the actual behaviour under test. """ from app.middleware.rate_limit import limiter limiter._storage.reset() yield @pytest_asyncio.fixture async def client() -> AsyncGenerator[AsyncClient, None]: app.dependency_overrides[get_db] = override_get_db async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac app.dependency_overrides.clear() @pytest_asyncio.fixture async def admin_user(): """Create an admin user in the test database and return it.""" async with TestSessionLocal() as session: user = User( email="admin@example.com", hashed_password=hash_password("testpassword"), is_active=True, ) session.add(user) await session.commit() await session.refresh(user) return user @pytest_asyncio.fixture async def admin_token(client: AsyncClient, admin_user: User) -> str: """Log in as the admin user and return the Bearer access token.""" response = await client.post( "/api/v1/auth/login", json={"email": "admin@example.com", "password": "testpassword"}, ) assert response.status_code == 200, f"Login failed: {response.text}" return response.json()["access_token"] @pytest_asyncio.fixture async def db_session() -> AsyncGenerator[AsyncSession, None]: """Yield a live test-database session for direct state manipulation in tests. Useful when a test needs to insert or update rows outside the HTTP layer (e.g. marking a user inactive before testing the login rejection). Changes must be committed explicitly by the caller. """ async with TestSessionLocal() as session: yield session