"""E2E test fixtures with isolated test server.
Provides fixtures for running E2E tests with:
- Isolated SQLite database per test session
- In-memory fake Keycloak (no external server needed)
- Test server on random port
- Automatic test user creation and authentication
"""
import asyncio
import contextlib
import os
import socket
import tempfile
from typing import Any
import httpx
import pytest
from dishka import Provider, Scope, make_async_container, provide
from dishka.integrations.fastapi import setup_dishka
from fastapi import FastAPI
from playwright.sync_api import Browser, BrowserContext
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from app.infrastructure.auth import KeycloakAuthClient
from app.infrastructure.config.settings import Settings
from tests.e2e.fake_keycloak import FakeKeycloakClient
def pytest_configure(config):
"""Disable pytest-asyncio for E2E tests.
pytest-playwright manages its own event loop and conflicts
with pytest-asyncio. We disable asyncio_mode for E2E tests.
"""
if hasattr(config, "option") and hasattr(config.option, "asyncio_mode"):
config.option.asyncio_mode = None
def _get_free_port() -> int:
"""Get a free TCP port from the OS."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture(scope="session")
def browser_type_launch_args() -> dict:
"""Return launch args for browser - ensure headless mode."""
return {"headless": True}
@pytest.fixture(scope="session")
def test_db_path() -> str:
"""Create temporary database file for test session."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="blog_e2e_")
os.close(fd)
yield path
with contextlib.suppress(FileNotFoundError):
os.unlink(path)
@pytest.fixture(scope="session")
def test_database_url(test_db_path: str) -> str:
"""Build database URL for test database."""
return f"sqlite+aiosqlite:///{test_db_path}"
@pytest.fixture(scope="session")
def test_settings(test_database_url: str) -> Settings:
"""Create test settings with isolated database."""
return Settings(
environment="dev",
app={"name": "Blog E2E Test", "debug": True, "host": "127.0.0.1", "port": 0},
db={"url": test_database_url, "echo": False},
kc={"server_url": "http://fake-keycloak:8080", "realm": "test", "client_id": "test"},
security={"secret_key": "test-secret-key-not-for-production"},
)
@pytest.fixture(scope="session")
def test_engine(test_database_url: str):
"""Create database engine for test session."""
import asyncio
engine = create_async_engine(
test_database_url,
echo=False,
future=True,
)
yield engine
# Cleanup
asyncio.run(engine.dispose())
@pytest.fixture(scope="session")
def fake_keycloak():
"""Create fake Keycloak client for testing."""
client = FakeKeycloakClient(token_ttl=3600)
yield client
client.clear()
class FakeKeycloakProvider(Provider):
"""Provider that supplies fake Keycloak client."""
def __init__(self, fake_client: FakeKeycloakClient) -> None:
"""Initialize with fake client."""
self._fake_client = fake_client
super().__init__()
@provide(scope=Scope.APP)
def get_keycloak_client(self) -> KeycloakAuthClient:
"""Provide fake Keycloak client."""
return self._fake_client
@pytest.fixture(scope="session")
def test_server(
test_settings: Settings,
test_engine: AsyncEngine,
fake_keycloak: FakeKeycloakClient,
):
"""Start test server on random port using threading for sync compatibility."""
import threading
import time
from app.infrastructure.database.models import Base
from app.presentation import router
from app.presentation.web import router as web_router
from app.presentation.web.error_handlers import register_error_handlers
from app.presentation.web.flash import setup_flash_manager
port = _get_free_port()
base_url = f"http://127.0.0.1:{port}"
print(f"\n[TestServer] Starting server on port {port}")
# Initialize database using asyncio.run
async def init_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
asyncio.run(init_db())
print("[TestServer] Database initialized")
except Exception as e:
print(f"[TestServer] Database init failed: {e}")
raise
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.infrastructure.di.providers import (
DatabaseProvider,
RepositoryProvider,
TransactionManagerProvider,
UseCaseProvider,
)
app = FastAPI(
title=test_settings.app.name,
debug=test_settings.app.debug,
docs_url="/docs",
redoc_url="/redoc",
)
container = make_async_container(
DatabaseProvider(),
RepositoryProvider(),
TransactionManagerProvider(),
UseCaseProvider(),
FakeKeycloakProvider(fake_keycloak),
)
setup_dishka(container, app)
from app.infrastructure import register_exception_handlers
register_exception_handlers(app)
register_error_handlers(app)
@app.middleware("http")
async def flash_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
await setup_flash_manager(request)
response = await call_next(request)
return response
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root_redirect() -> Response:
from fastapi.responses import HTMLResponse
return HTMLResponse(
content='', status_code=200
)
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "ok", "env": "e2e-test"}
# Include main routers
app.include_router(router, prefix="/api")
app.include_router(web_router)
# Add fake auth routes instead of including auth_router
async def fake_login(request: Request, redirect: str = "/web/") -> Response:
from fastapi.responses import HTMLResponse
html = f'''
Test Login
Test Login Page
'''
return HTMLResponse(content=html)
@app.post("/auth/callback")
async def fake_callback(request: Request) -> Response:
from fastapi.responses import RedirectResponse
form = await request.form()
username = form.get("username", "testuser")
redirect = form.get("redirect", "/web/")
try:
user = fake_keycloak.create_user(
username=username,
password="test",
email=f"{username}@test.com",
roles=["admin" if username == "admin" else "user"],
)
except ValueError:
_ = fake_keycloak._users.get(username)
token = fake_keycloak.login(username, "test")
response = RedirectResponse(url=redirect, status_code=302)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
)
return response
@app.get("/auth/logout")
async def fake_logout(request: Request) -> Response:
from fastapi.responses import RedirectResponse
response = RedirectResponse(url="/web/", status_code=302)
response.delete_cookie(key="access_token")
return response
app.mount("/static", StaticFiles(directory="static"), name="static")
import uvicorn
from uvicorn import Config
config = Config(app=app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
# Run server in a separate thread with error handling
server_exception = None
def run_server():
nonlocal server_exception
try:
asyncio.run(server.serve())
except Exception as e:
server_exception = e
print(f"[TestServer] Server error: {e}")
import traceback
traceback.print_exc()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
print("[TestServer] Server thread started")
# Wait for server to be ready using sync httpx
server_started = False
last_error = None
for attempt in range(50):
try:
response = httpx.get(f"{base_url}/health", timeout=0.5)
if response.status_code == 200:
server_started = True
print(f"[TestServer] Server ready after {attempt + 1} attempts")
break
except (httpx.ConnectError, httpx.TimeoutException) as e:
last_error = e
time.sleep(0.1)
if not server_started:
print(f"[TestServer] Server failed to start after 50 attempts. Last error: {last_error}")
server.should_exit = True
raise RuntimeError(f"Test server failed to start after 50 attempts on port {port}")
# Test that web routes work before yielding
try:
test_response = httpx.get(f"{base_url}/web/", timeout=2.0, follow_redirects=True)
print(f"[TestServer] Test /web/: {test_response.status_code}")
except Exception as e:
print(f"[TestServer] Test /web/ failed: {e}")
# Test auth redirect
try:
test_response = httpx.get(f"{base_url}/web/posts/new", timeout=2.0, follow_redirects=True)
print(f"[TestServer] Test /web/posts/new: {test_response.status_code}")
print(f"[TestServer] Final URL: {test_response.url}")
except Exception as e:
print(f"[TestServer] Test /web/posts/new failed: {e}")
print(f"[TestServer] Yielding server info: {base_url}")
yield {"base_url": base_url, "port": port, "fake_keycloak": fake_keycloak}
# Cleanup
print("[TestServer] Shutting down server...")
server.should_exit = True
server_thread.join(timeout=5.0)
print("[TestServer] Server shutdown complete")
@pytest.fixture(scope="session")
def base_url(test_server: dict[str, Any]) -> str:
"""Get base URL of running test server."""
return test_server["base_url"]
@pytest.fixture(scope="session")
def keycloak_client(test_server: dict[str, Any]) -> FakeKeycloakClient:
"""Get fake Keycloak client from test server."""
return test_server["fake_keycloak"]
@pytest.fixture
def test_user_data() -> dict[str, str]:
"""Generate test user data."""
import uuid
unique_id = uuid.uuid4().hex[:8]
return {
"username": f"testuser_{unique_id}",
"email": f"test_{unique_id}@example.com",
"password": "TestPass123!",
}
@pytest.fixture
def authenticated_context(
browser: Browser,
keycloak_client: FakeKeycloakClient,
test_user_data: dict[str, str],
base_url: str,
) -> BrowserContext:
"""Create authenticated browser context with logged-in user."""
user = keycloak_client.create_user(
username=test_user_data["username"],
password=test_user_data["password"],
email=test_user_data["email"],
roles=["user"],
)
token = keycloak_client.login(user.username, user.password)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
)
cookie_domain = base_url.replace("http://", "").replace("https://", "").split(":")[0]
context.add_cookies(
[
{
"name": "access_token",
"value": token,
"domain": cookie_domain,
"path": "/",
"httpOnly": True,
"secure": False,
}
]
)
yield context
context.close()
@pytest.fixture
def authenticated_page(authenticated_context: BrowserContext):
"""Create authenticated page for testing."""
page = authenticated_context.new_page()
yield page
page.close()
@pytest.fixture
def admin_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
"""Create admin user and return credentials with token."""
import uuid
unique_id = uuid.uuid4().hex[:8]
username = f"admin_{unique_id}"
password = "AdminPass123!"
user = keycloak_client.create_user(
username=username,
password=password,
email=f"admin_{unique_id}@example.com",
roles=["user", "admin"],
)
token = keycloak_client.login(username, password)
return {
"id": user.id,
"username": username,
"password": password,
"email": user.email,
"token": token,
"roles": user.roles,
}
@pytest.fixture
def regular_user(keycloak_client: FakeKeycloakClient) -> dict[str, str]:
"""Create regular user and return credentials with token."""
import uuid
unique_id = uuid.uuid4().hex[:8]
username = f"user_{unique_id}"
password = "UserPass123!"
user = keycloak_client.create_user(
username=username,
password=password,
email=f"user_{unique_id}@example.com",
roles=["user"],
)
token = keycloak_client.login(username, password)
return {
"id": user.id,
"username": username,
"password": password,
"email": user.email,
"token": token,
"roles": user.roles,
}