""" E2E Test Fixtures using Testcontainers. This module provides fixtures for end-to-end testing with: - Real PostgreSQL containers (via Testcontainers) - ASGI test clients connected to real database Requirements: - Docker must be running - Install E2E deps: make install-e2e (or uv sync --extra e2e) Usage: make test-e2e # Run all E2E tests make test-e2e-schema # Run schema tests only """ import os import subprocess # Disable Testcontainers Ryuk (Reaper) to avoid port mapping issues in some environments. # Ryuk is used for automatic cleanup of containers, but can cause issues with port 8080. # Containers will still be cleaned up when the test session ends via explicit stop() calls. os.environ.setdefault("TESTCONTAINERS_RYUK_DISABLED", "true") import pytest import pytest_asyncio def is_docker_available() -> bool: """Check if Docker daemon is accessible.""" try: result = subprocess.run( ["docker", "info"], capture_output=True, timeout=10, ) return result.returncode == 0 except (subprocess.TimeoutExpired, FileNotFoundError): return False DOCKER_AVAILABLE = is_docker_available() # Conditional import - only import testcontainers if Docker is available if DOCKER_AVAILABLE: try: from testcontainers.postgres import PostgresContainer TESTCONTAINERS_AVAILABLE = True except ImportError: TESTCONTAINERS_AVAILABLE = False else: TESTCONTAINERS_AVAILABLE = False def pytest_collection_modifyitems(config, items): """Skip E2E tests if Docker is not available.""" if not DOCKER_AVAILABLE: skip_marker = pytest.mark.skip( reason="Docker not available - start Docker to run E2E tests" ) for item in items: if ( "e2e" in item.keywords or "postgres" in item.keywords or "schemathesis" in item.keywords ): item.add_marker(skip_marker) elif not TESTCONTAINERS_AVAILABLE: skip_marker = pytest.mark.skip( reason="testcontainers not installed - run: make install-e2e" ) for item in items: if "e2e" in item.keywords or "postgres" in item.keywords: item.add_marker(skip_marker) # Store container at module level to share across tests _postgres_container = None _postgres_url = None @pytest.fixture(scope="session") def postgres_container(): """ Session-scoped PostgreSQL container. Starts once per test session and is shared across all E2E tests. This significantly improves test performance compared to per-test containers. """ global _postgres_container, _postgres_url if not DOCKER_AVAILABLE: pytest.skip("Docker not available") if not TESTCONTAINERS_AVAILABLE: pytest.skip("testcontainers not installed - run: make install-e2e") _postgres_container = PostgresContainer("postgres:17-alpine") _postgres_container.start() _postgres_url = _postgres_container.get_connection_url() yield _postgres_container _postgres_container.stop() _postgres_container = None _postgres_url = None @pytest.fixture(scope="session") def postgres_url(postgres_container) -> str: """Get sync PostgreSQL URL from container.""" return _postgres_url @pytest.fixture(scope="session") def async_postgres_url(postgres_url) -> str: """ Get async-compatible PostgreSQL URL from container. Converts the sync URL to use asyncpg driver. Testcontainers returns postgresql+psycopg2:// format. """ # Testcontainers uses psycopg2 by default, convert to asyncpg return postgres_url.replace( "postgresql+psycopg2://", "postgresql+asyncpg://" ).replace("postgresql://", "postgresql+asyncpg://") @pytest_asyncio.fixture async def e2e_db_session(async_postgres_url): """ Function-scoped async database session with fresh tables. Each test gets: - Fresh database schema (tables dropped and recreated) - Isolated session that doesn't leak state between tests """ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from app.models.base import Base engine = create_async_engine(async_postgres_url, echo=False) # Drop and recreate all tables for isolation async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) AsyncSessionLocal = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, ) async with AsyncSessionLocal() as session: yield session await engine.dispose() @pytest_asyncio.fixture async def e2e_client(async_postgres_url): """ ASGI test client with real PostgreSQL backend. Provides an httpx AsyncClient connected to the FastAPI app, with database dependency overridden to use real PostgreSQL. """ os.environ["IS_TEST"] = "True" from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from app.core.database import get_db from app.main import app from app.models.base import Base engine = create_async_engine(async_postgres_url, echo=False) # Drop and recreate all tables for isolation async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) AsyncTestingSessionLocal = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False, ) async def override_get_db(): async with AsyncTestingSessionLocal() as session: yield session app.dependency_overrides[get_db] = override_get_db transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://e2e-test") as client: yield client app.dependency_overrides.clear() await engine.dispose() @pytest_asyncio.fixture async def e2e_superuser(e2e_client): """ Create a superuser and return credentials + tokens. Returns dict with: email, password, tokens, user_id """ from uuid import uuid4 email = f"admin-{uuid4().hex[:8]}@example.com" password = "SuperAdmin123!" # Register via API first to get proper password hashing await e2e_client.post( "/api/v1/auth/register", json={ "email": email, "password": password, "first_name": "Super", "last_name": "Admin", }, ) # Login to get tokens login_resp = await e2e_client.post( "/api/v1/auth/login", json={"email": email, "password": password}, ) tokens = login_resp.json() # Now we need to make this user a superuser directly via SQL # Get the db session from the client's override from sqlalchemy import text from app.core.database import get_db from app.main import app async for db in app.dependency_overrides[get_db](): # Update user to be superuser await db.execute( text("UPDATE users SET is_superuser = true WHERE email = :email"), {"email": email}, ) await db.commit() # Get user ID result = await db.execute( text("SELECT id FROM users WHERE email = :email"), {"email": email}, ) user_id = str(result.scalar()) break return { "email": email, "password": password, "tokens": tokens, "user_id": user_id, } @pytest_asyncio.fixture async def e2e_org_with_members(e2e_client, e2e_superuser): """ Create an organization with owner and member. Returns dict with: org_id, org_slug, owner (tokens), member (tokens) """ from uuid import uuid4 # Create organization via admin API org_name = f"Test Org {uuid4().hex[:8]}" org_slug = f"test-org-{uuid4().hex[:8]}" create_resp = await e2e_client.post( "/api/v1/admin/organizations", headers={"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"}, json={ "name": org_name, "slug": org_slug, "description": "Test organization for E2E tests", }, ) org_data = create_resp.json() org_id = org_data["id"] # Create owner user owner_email = f"owner-{uuid4().hex[:8]}@example.com" owner_password = "OwnerPass123!" await e2e_client.post( "/api/v1/auth/register", json={ "email": owner_email, "password": owner_password, "first_name": "Org", "last_name": "Owner", }, ) owner_login = await e2e_client.post( "/api/v1/auth/login", json={"email": owner_email, "password": owner_password}, ) owner_tokens = owner_login.json() # Get owner user ID owner_me = await e2e_client.get( "/api/v1/users/me", headers={"Authorization": f"Bearer {owner_tokens['access_token']}"}, ) owner_id = owner_me.json()["id"] # Add owner to organization as owner role await e2e_client.post( f"/api/v1/admin/organizations/{org_id}/members", headers={"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"}, json={"user_id": owner_id, "role": "owner"}, ) # Create member user member_email = f"member-{uuid4().hex[:8]}@example.com" member_password = "MemberPass123!" await e2e_client.post( "/api/v1/auth/register", json={ "email": member_email, "password": member_password, "first_name": "Org", "last_name": "Member", }, ) member_login = await e2e_client.post( "/api/v1/auth/login", json={"email": member_email, "password": member_password}, ) member_tokens = member_login.json() # Get member user ID member_me = await e2e_client.get( "/api/v1/users/me", headers={"Authorization": f"Bearer {member_tokens['access_token']}"}, ) member_id = member_me.json()["id"] # Add member to organization await e2e_client.post( f"/api/v1/admin/organizations/{org_id}/members", headers={"Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}"}, json={"user_id": member_id, "role": "member"}, ) return { "org_id": org_id, "org_slug": org_slug, "org_name": org_name, "owner": {"email": owner_email, "tokens": owner_tokens, "user_id": owner_id}, "member": { "email": member_email, "tokens": member_tokens, "user_id": member_id, }, } # NOTE: Class-scoped fixtures for E2E tests were attempted but have fundamental # issues with pytest-asyncio + SQLAlchemy/asyncpg event loop management. # The function-scoped fixtures above provide proper test isolation. # Performance optimization would require significant infrastructure changes.