140 lines
4.5 KiB
Python
140 lines
4.5 KiB
Python
|
|
"""
|
||
|
|
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
|