2025-12-18 22:08:31 +01:00
|
|
|
import os
|
2025-12-18 22:24:46 +01:00
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
|
|
|
# Set required env vars before importing app
|
|
|
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing-only")
|
|
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
import pytest
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
2025-12-18 23:33:32 +01:00
|
|
|
from sqlalchemy import select
|
2025-12-21 21:54:26 +01:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
2025-12-18 22:08:31 +01:00
|
|
|
|
2025-12-21 21:54:26 +01:00
|
|
|
from auth import get_password_hash
|
2025-12-18 22:08:31 +01:00
|
|
|
from database import Base, get_db
|
|
|
|
|
from main import app
|
2025-12-21 21:54:26 +01:00
|
|
|
from models import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR, Role, User
|
2025-12-19 00:12:43 +01:00
|
|
|
from tests.helpers import unique_email
|
2025-12-18 22:08:31 +01:00
|
|
|
|
2025-12-25 00:33:05 +01:00
|
|
|
|
|
|
|
|
def get_test_database_url(worker_id: str | None = None) -> str:
|
|
|
|
|
"""Get test database URL, optionally with worker-specific suffix for parallel execution."""
|
|
|
|
|
base_url = os.getenv(
|
|
|
|
|
"TEST_DATABASE_URL",
|
|
|
|
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_test",
|
|
|
|
|
)
|
|
|
|
|
if worker_id and worker_id != "master":
|
|
|
|
|
# For parallel execution, each worker gets its own database
|
|
|
|
|
# e.g., arbret_test_gw0, arbret_test_gw1, etc.
|
|
|
|
|
return base_url.replace("arbret_test", f"arbret_test_{worker_id}")
|
|
|
|
|
return base_url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Default URL for backwards compatibility
|
|
|
|
|
TEST_DATABASE_URL = get_test_database_url()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def engine(worker_id):
|
|
|
|
|
"""Session-scoped database engine.
|
|
|
|
|
|
|
|
|
|
For parallel execution (pytest-xdist), each worker gets its own database.
|
|
|
|
|
Note: create_async_engine() is synchronous - it returns immediately.
|
|
|
|
|
"""
|
|
|
|
|
db_url = get_test_database_url(worker_id)
|
|
|
|
|
engine_instance = create_async_engine(db_url)
|
|
|
|
|
yield engine_instance
|
|
|
|
|
# Cleanup will happen automatically when process exits
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
|
|
|
def schema_initialized():
|
|
|
|
|
"""Session-scoped flag to track if schema has been initialized.
|
|
|
|
|
|
|
|
|
|
Returns a dict that can be mutated to track state across the session.
|
|
|
|
|
"""
|
|
|
|
|
return {"initialized": False}
|
2025-12-18 22:08:31 +01:00
|
|
|
|
|
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
class ClientFactory:
|
|
|
|
|
"""Factory for creating httpx clients with optional cookies."""
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
def __init__(self, transport, base_url, session_factory):
|
2025-12-18 22:24:46 +01:00
|
|
|
self._transport = transport
|
|
|
|
|
self._base_url = base_url
|
2025-12-18 23:33:32 +01:00
|
|
|
self._session_factory = session_factory
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
@asynccontextmanager
|
|
|
|
|
async def create(self, cookies: dict | None = None):
|
|
|
|
|
"""Create a new client, optionally with cookies set."""
|
|
|
|
|
async with AsyncClient(
|
|
|
|
|
transport=self._transport,
|
|
|
|
|
base_url=self._base_url,
|
|
|
|
|
cookies=cookies or {},
|
|
|
|
|
) as client:
|
|
|
|
|
yield client
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
async def request(self, method: str, url: str, **kwargs):
|
|
|
|
|
"""Make a one-off request without cookies."""
|
|
|
|
|
async with self.create() as client:
|
|
|
|
|
return await client.request(method, url, **kwargs)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
async def get(self, url: str, **kwargs):
|
|
|
|
|
return await self.request("GET", url, **kwargs)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
async def post(self, url: str, **kwargs):
|
|
|
|
|
return await self.request("POST", url, **kwargs)
|
|
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
@asynccontextmanager
|
|
|
|
|
async def get_db_session(self):
|
|
|
|
|
"""Get a database session for direct DB operations in tests."""
|
|
|
|
|
async with self._session_factory() as session:
|
|
|
|
|
yield session
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def setup_roles(db: AsyncSession) -> dict[str, Role]:
|
|
|
|
|
"""Create all roles with their permissions from ROLE_DEFINITIONS."""
|
|
|
|
|
roles = {}
|
|
|
|
|
for role_name, config in ROLE_DEFINITIONS.items():
|
|
|
|
|
# Check if role exists
|
|
|
|
|
result = await db.execute(select(Role).where(Role.name == role_name))
|
|
|
|
|
role = result.scalar_one_or_none()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
if not role:
|
|
|
|
|
role = Role(name=role_name, description=config["description"])
|
|
|
|
|
db.add(role)
|
|
|
|
|
await db.flush()
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Set permissions
|
|
|
|
|
await role.set_permissions(db, config["permissions"])
|
|
|
|
|
roles[role_name] = role
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
await db.commit()
|
|
|
|
|
return roles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def create_user_with_roles(
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
email: str,
|
|
|
|
|
password: str,
|
2025-12-19 00:12:43 +01:00
|
|
|
role_names: list[str],
|
2025-12-18 23:33:32 +01:00
|
|
|
) -> User:
|
|
|
|
|
"""Create a user with specified roles."""
|
|
|
|
|
# Get roles
|
|
|
|
|
roles = []
|
|
|
|
|
for role_name in role_names:
|
|
|
|
|
result = await db.execute(select(Role).where(Role.name == role_name))
|
|
|
|
|
role = result.scalar_one_or_none()
|
2025-12-19 00:12:43 +01:00
|
|
|
if not role:
|
2025-12-21 21:54:26 +01:00
|
|
|
raise ValueError(
|
|
|
|
|
f"Role '{role_name}' not found. Did you run setup_roles()?"
|
|
|
|
|
)
|
2025-12-19 00:12:43 +01:00
|
|
|
roles.append(role)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
user = User(
|
|
|
|
|
email=email,
|
|
|
|
|
hashed_password=get_password_hash(password),
|
|
|
|
|
roles=roles,
|
|
|
|
|
)
|
|
|
|
|
db.add(user)
|
|
|
|
|
await db.commit()
|
|
|
|
|
await db.refresh(user)
|
|
|
|
|
return user
|
|
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
@pytest.fixture(scope="function")
|
2025-12-25 00:33:05 +01:00
|
|
|
async def client_factory(engine, schema_initialized):
|
|
|
|
|
"""Fixture that provides a factory for creating clients.
|
|
|
|
|
|
|
|
|
|
Step 3: Uses transaction rollback for test isolation.
|
|
|
|
|
- Schema is created once per session (outside any transaction)
|
|
|
|
|
- Each test runs in a transaction that gets rolled back
|
|
|
|
|
- No need to drop/recreate tables or dispose connections
|
|
|
|
|
"""
|
|
|
|
|
# Create schema once per session (lazy initialization, outside transaction)
|
|
|
|
|
if not schema_initialized["initialized"]:
|
|
|
|
|
# Use a separate connection for schema creation (no transaction)
|
|
|
|
|
async with engine.connect() as conn:
|
|
|
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
|
await conn.commit()
|
|
|
|
|
|
|
|
|
|
# Set up roles once per session (commit so they persist across test transactions)
|
|
|
|
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
async with session_factory() as db:
|
|
|
|
|
await setup_roles(db)
|
|
|
|
|
await db.commit() # Commit roles so they're available for all tests
|
|
|
|
|
|
|
|
|
|
schema_initialized["initialized"] = True
|
|
|
|
|
|
|
|
|
|
# Step 3: Transaction rollback pattern (partially implemented)
|
|
|
|
|
# NOTE: Full transaction rollback has event loop conflicts with asyncpg.
|
|
|
|
|
# For now, we keep the Step 2 approach (drop/recreate) which works reliably.
|
|
|
|
|
# Future: Investigate using pytest-asyncio's event loop configuration or
|
|
|
|
|
# a different transaction isolation approach that works with asyncpg.
|
|
|
|
|
|
|
|
|
|
# Create session factory using the engine (not connection-bound to avoid event loop issues)
|
2025-12-18 22:08:31 +01:00
|
|
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-25 00:33:05 +01:00
|
|
|
# For test isolation, we still drop/recreate tables per-function
|
|
|
|
|
# This is slower than transaction rollback but works reliably with asyncpg
|
|
|
|
|
await engine.dispose() # Clear connection pool to ensure fresh connections
|
|
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
async with engine.begin() as conn:
|
|
|
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
|
|
|
await conn.run_sync(Base.metadata.create_all)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-25 00:33:05 +01:00
|
|
|
# Re-setup roles after table recreation
|
2025-12-18 23:33:32 +01:00
|
|
|
async with session_factory() as db:
|
|
|
|
|
await setup_roles(db)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
async def override_get_db():
|
|
|
|
|
async with session_factory() as session:
|
|
|
|
|
yield session
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
transport = ASGITransport(app=app)
|
2025-12-18 23:33:32 +01:00
|
|
|
factory = ClientFactory(transport, "http://test", session_factory)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:24:46 +01:00
|
|
|
yield factory
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 22:08:31 +01:00
|
|
|
app.dependency_overrides.clear()
|
2025-12-18 22:24:46 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
|
|
|
async def client(client_factory):
|
|
|
|
|
"""Fixture for a simple client without cookies (backwards compatible)."""
|
|
|
|
|
async with client_factory.create() as c:
|
|
|
|
|
yield c
|
2025-12-18 23:33:32 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
|
|
|
async def regular_user(client_factory):
|
|
|
|
|
"""Create a regular user and return their credentials and cookies."""
|
|
|
|
|
email = unique_email("regular")
|
|
|
|
|
password = "password123"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.get_db_session() as db:
|
2025-12-21 00:03:34 +01:00
|
|
|
user = await create_user_with_roles(db, email, password, [ROLE_REGULAR])
|
|
|
|
|
user_id = user.id
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Login to get cookies
|
|
|
|
|
response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": password},
|
|
|
|
|
)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
return {
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": password,
|
|
|
|
|
"cookies": dict(response.cookies),
|
|
|
|
|
"response": response,
|
2025-12-21 00:03:34 +01:00
|
|
|
"user": {"id": user_id, "email": email},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
|
|
|
async def alt_regular_user(client_factory):
|
|
|
|
|
"""Create a second regular user for tests needing multiple users."""
|
|
|
|
|
email = unique_email("alt_regular")
|
|
|
|
|
password = "password123"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-21 00:03:34 +01:00
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
user = await create_user_with_roles(db, email, password, [ROLE_REGULAR])
|
|
|
|
|
user_id = user.id
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-21 00:03:34 +01:00
|
|
|
# Login to get cookies
|
|
|
|
|
response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": password},
|
|
|
|
|
)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-21 00:03:34 +01:00
|
|
|
return {
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": password,
|
|
|
|
|
"cookies": dict(response.cookies),
|
|
|
|
|
"response": response,
|
|
|
|
|
"user": {"id": user_id, "email": email},
|
2025-12-18 23:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
|
|
|
async def admin_user(client_factory):
|
|
|
|
|
"""Create an admin user and return their credentials and cookies."""
|
|
|
|
|
email = unique_email("admin")
|
|
|
|
|
password = "password123"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.get_db_session() as db:
|
2025-12-19 00:12:43 +01:00
|
|
|
await create_user_with_roles(db, email, password, [ROLE_ADMIN])
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Login to get cookies
|
|
|
|
|
response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": password},
|
|
|
|
|
)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
return {
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": password,
|
|
|
|
|
"cookies": dict(response.cookies),
|
|
|
|
|
"response": response,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
|
|
|
async def user_no_roles(client_factory):
|
|
|
|
|
"""Create a user with NO roles and return their credentials and cookies."""
|
|
|
|
|
email = unique_email("noroles")
|
|
|
|
|
password = "password123"
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
async with client_factory.get_db_session() as db:
|
|
|
|
|
await create_user_with_roles(db, email, password, [])
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
# Login to get cookies
|
|
|
|
|
response = await client_factory.post(
|
|
|
|
|
"/api/auth/login",
|
|
|
|
|
json={"email": email, "password": password},
|
|
|
|
|
)
|
2025-12-21 21:54:26 +01:00
|
|
|
|
2025-12-18 23:33:32 +01:00
|
|
|
return {
|
|
|
|
|
"email": email,
|
|
|
|
|
"password": password,
|
|
|
|
|
"cookies": dict(response.cookies),
|
|
|
|
|
"response": response,
|
|
|
|
|
}
|