"""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, }