Files
syndarix/backend/tests/e2e/conftest.py
Felipe Cardoso 8e16e2645e test(forms): add unit tests for FormTextarea and FormSelect components
- Add comprehensive test coverage for FormTextarea and FormSelect components to validate rendering, accessibility, props forwarding, error handling, and behavior.
- Introduced function-scoped fixtures in e2e tests to ensure test isolation and address event loop issues with pytest-asyncio and SQLAlchemy.
2026-01-06 17:54:49 +01:00

377 lines
11 KiB
Python

"""
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.