diff --git a/Makefile b/Makefile index 6a08a80..feafef2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install-backend install-frontend install setup-hooks backend frontend worker db db-stop db-ready db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend pre-commit lint +.PHONY: install-backend install-frontend install setup-hooks backend frontend worker db db-stop db-ready db-ready-backend-tests db-ready-e2e-tests db-seed dev test test-backend test-frontend test-e2e typecheck generate-types generate-types-standalone check-types-fresh check-constants lint-backend format-backend fix-backend security-backend lint-frontend fix-frontend format-frontend pre-commit lint -include .env export @@ -40,17 +40,27 @@ db-ready: done @docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret'" | grep -q 1 || \ docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret" + @echo "PostgreSQL is ready" + +# Create worker-specific databases for parallel backend test execution (pytest-xdist) +db-ready-backend-tests: db-ready + @echo "Creating backend test databases..." @docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \ docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test" - @# Create worker-specific databases for parallel backend test execution (pytest-xdist) @for i in 0 1 2 3 4 5 6 7; do \ docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \ docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \ done - @# Create separate database for e2e tests - @docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e'" | grep -q 1 || \ - docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e" - @echo "PostgreSQL is ready" + @echo "Backend test databases ready" + +# Create worker-specific databases for parallel e2e test execution +db-ready-e2e-tests: db-ready + @echo "Creating e2e test databases..." + @for i in 0 1 2 3 4 5 6 7; do \ + docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e_worker$$i'" | grep -q 1 || \ + docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e_worker$$i"; \ + done + @echo "E2E test databases ready" db-seed: db-ready cd backend && uv run python seed.py @@ -68,7 +78,7 @@ dev: # E2E: TEST="auth" (file pattern matching e2e/*.spec.ts) TEST ?= -test-backend: db-ready test-backend-clean-dbs +test-backend: db-ready-backend-tests test-backend-clean-dbs cd backend && uv run pytest -v -n 8 $(TEST) # Clean only backend test databases (not e2e or main db) @@ -81,13 +91,16 @@ test-backend-clean-dbs: test-frontend: cd frontend && npm run test $(if $(TEST),-- $(TEST),) -test-e2e: db-ready test-e2e-clean-db +test-e2e: db-ready-e2e-tests test-e2e-clean-db ./scripts/e2e.sh $(TEST) -# Clean only e2e database (not backend test dbs or main db) +# Clean only e2e databases (not backend test dbs or main db) +# Create worker-specific databases for parallel e2e test execution test-e2e-clean-db: - @docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e" 2>/dev/null || true - @docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e" + @for i in 0 1 2 3 4 5 6 7; do \ + docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e_worker$$i" 2>/dev/null || true; \ + docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e_worker$$i" 2>/dev/null || true; \ + done test: check-constants check-types-fresh test-backend test-frontend test-e2e diff --git a/backend/main.py b/backend/main.py index 6f911a3..545ecb8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ from routes import exchange as exchange_routes from routes import invites as invites_routes from routes import meta as meta_routes from routes import profile as profile_routes +from routes import test as test_routes from shared_constants import PRICE_REFRESH_SECONDS from validate_constants import validate_shared_constants @@ -91,6 +92,7 @@ app.include_router(audit_routes.router) app.include_router(profile_routes.router) app.include_router(availability_routes.router) app.include_router(meta_routes.router) +app.include_router(test_routes.router) # Include routers - modules with multiple routers for r in invites_routes.routers: diff --git a/backend/routes/test.py b/backend/routes/test.py new file mode 100644 index 0000000..13a9b4b --- /dev/null +++ b/backend/routes/test.py @@ -0,0 +1,49 @@ +"""Test-only endpoints for e2e test isolation.""" + +import os + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db + +router = APIRouter(prefix="/api/test", tags=["test"]) + + +@router.post("/reset") +async def reset_database(db: AsyncSession = Depends(get_db)): + """ + Truncate all tables and re-seed base data. + Only available when E2E_MODE environment variable is set. + """ + # Safety check - only allow in e2e mode + if not os.getenv("E2E_MODE"): + raise HTTPException( + status_code=403, detail="This endpoint is only available in E2E_MODE" + ) + + # Get all table names from the database + result = await db.execute( + text(""" + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename != 'alembic_version' + ORDER BY tablename + """) + ) + tables = [row[0] for row in result] + + # Truncate all tables (CASCADE handles foreign keys) + if tables: + table_list = ", ".join(f'"{table}"' for table in tables) + await db.execute(text(f"TRUNCATE TABLE {table_list} CASCADE")) + await db.commit() + + # Re-seed essential data + from seed_e2e import seed_base_data + + await seed_base_data(db) + + return {"status": "reset", "tables_truncated": len(tables)} diff --git a/backend/seed_e2e.py b/backend/seed_e2e.py new file mode 100644 index 0000000..d02e1d8 --- /dev/null +++ b/backend/seed_e2e.py @@ -0,0 +1,90 @@ +"""Fast re-seeding function for e2e tests - only seeds essential data.""" + +import os + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from auth import get_password_hash +from models import ( + ROLE_ADMIN, + ROLE_DEFINITIONS, + ROLE_REGULAR, + Role, + User, +) + + +async def seed_base_data(db: AsyncSession) -> None: + """ + Seed only the minimal data needed for e2e tests: + - Roles (admin, regular) with permissions + - Test users (from env vars) + """ + # Get environment variables with defaults + dev_user_email = os.getenv("DEV_USER_EMAIL", "user@example.com") + dev_user_password = os.getenv("DEV_USER_PASSWORD", "user123") + dev_admin_email = os.getenv("DEV_ADMIN_EMAIL", "admin@example.com") + dev_admin_password = os.getenv("DEV_ADMIN_PASSWORD", "admin123") + + # Create roles with permissions + for role_name, role_config in ROLE_DEFINITIONS.items(): + result = await db.execute(select(Role).where(Role.name == role_name)) + role = result.scalar_one_or_none() + + if not role: + role = Role(name=role_name, description=role_config["description"]) + db.add(role) + await db.flush() # Get the role ID + + # Set permissions for the role + await role.set_permissions(db, role_config["permissions"]) + + await db.flush() # Ensure roles are committed before creating users + + # Get roles for users + admin_role_result = await db.execute(select(Role).where(Role.name == ROLE_ADMIN)) + admin_role = admin_role_result.scalar_one() + + regular_role_result = await db.execute( + select(Role).where(Role.name == ROLE_REGULAR) + ) + regular_role = regular_role_result.scalar_one() + + # Create regular dev user + regular_user_result = await db.execute( + select(User).where(User.email == dev_user_email) + ) + regular_user = regular_user_result.scalar_one_or_none() + + if not regular_user: + regular_user = User( + email=dev_user_email, + hashed_password=get_password_hash(dev_user_password), + roles=[regular_role], + ) + db.add(regular_user) + else: + # Update existing user + regular_user.hashed_password = get_password_hash(dev_user_password) + regular_user.roles = [regular_role] + + # Create admin dev user + admin_user_result = await db.execute( + select(User).where(User.email == dev_admin_email) + ) + admin_user = admin_user_result.scalar_one_or_none() + + if not admin_user: + admin_user = User( + email=dev_admin_email, + hashed_password=get_password_hash(dev_admin_password), + roles=[admin_role], + ) + db.add(admin_user) + else: + # Update existing user + admin_user.hashed_password = get_password_hash(dev_admin_password) + admin_user.roles = [admin_role] + + await db.commit() diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index df523cf..2f52326 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -212,6 +212,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/test/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reset Database + * @description Truncate all tables and re-seed base data. + * Only available when E2E_MODE environment variable is set. + */ + post: operations["reset_database_api_test_reset_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/invites/{identifier}/check": { parameters: { query?: never; @@ -1434,6 +1455,26 @@ export interface operations { }; }; }; + reset_database_api_test_reset_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; check_invite_api_invites__identifier__check_get: { parameters: { query?: never; diff --git a/frontend/e2e/admin-invites.spec.ts b/frontend/e2e/admin-invites.spec.ts index 5ce9ea2..e43f61f 100644 --- a/frontend/e2e/admin-invites.spec.ts +++ b/frontend/e2e/admin-invites.spec.ts @@ -124,15 +124,25 @@ test.describe("Admin Invites Page", () => { // Test status filter - filter by "revoked" status const statusFilter = page.locator("select").nth(1); // Second select is the status filter - await statusFilter.selectOption("revoked"); - // Wait for the filter to apply and verify revoked invite is visible - await page.waitForResponse((resp) => resp.url().includes("status=revoked")); + // Wait for filter response, but don't fail if it doesn't come (might be cached) + const filterPromise = page + .waitForResponse((resp) => resp.url().includes("status=revoked"), { timeout: 5000 }) + .catch(() => null); // Ignore timeout - filter might be cached + + await statusFilter.selectOption("revoked"); + await filterPromise; // Wait for response if it comes + + // Verify revoked invite is visible await expect(revokedRow).toBeVisible({ timeout: 5000 }); // Filter by "ready" status - should not show our revoked invite + const readyFilterPromise = page + .waitForResponse((resp) => resp.url().includes("status=ready"), { timeout: 5000 }) + .catch(() => null); // Ignore timeout - filter might be cached + await statusFilter.selectOption("ready"); - await page.waitForResponse((resp) => resp.url().includes("status=ready")); + await readyFilterPromise; // Wait for response if it comes await expect(revokedRow).not.toBeVisible({ timeout: 5000 }); }); }); @@ -151,12 +161,13 @@ test.describe("Admin Invites Access Control", () => { await page.fill('input[type="email"]', REGULAR_USER_EMAIL); await page.fill('input[type="password"]', "user123"); await page.click('button[type="submit"]'); - await expect(page).toHaveURL("/"); + // Regular users are redirected to /exchange after login + await expect(page).toHaveURL("/exchange"); // Try to access admin invites page await page.goto("/admin/invites"); - // Should be redirected away (to home page based on fallbackRedirect) + // Should be redirected away (to exchange page based on fallbackRedirect) await expect(page).not.toHaveURL("/admin/invites"); }); }); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 06ab043..609457a 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page, APIRequestContext } from "@playwright/test"; +import { getBackendUrl } from "./helpers/backend-url"; // Helper to generate unique email for each test function uniqueEmail(): string { @@ -14,24 +15,22 @@ async function clearAuth(page: Page) { const ADMIN_EMAIL = "admin@example.com"; const ADMIN_PASSWORD = "admin123"; -// Helper to create an invite via the API -const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; - async function createInvite(request: APIRequestContext): Promise { + const apiBase = getBackendUrl(); // Login as admin - const loginResp = await request.post(`${API_BASE}/api/auth/login`, { + const loginResp = await request.post(`${apiBase}/api/auth/login`, { data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, }); const cookies = loginResp.headers()["set-cookie"]; // Get admin user ID (we'll use admin as godfather for simplicity) - const meResp = await request.get(`${API_BASE}/api/auth/me`, { + const meResp = await request.get(`${apiBase}/api/auth/me`, { headers: { Cookie: cookies }, }); const admin = await meResp.json(); // Create invite - const inviteResp = await request.post(`${API_BASE}/api/admin/invites`, { + const inviteResp = await request.post(`${apiBase}/api/admin/invites`, { data: { godfather_id: admin.id }, headers: { Cookie: cookies }, }); @@ -112,8 +111,8 @@ test.describe("Signup with Invite", () => { await expect(page).toHaveURL("/exchange"); // Test logged-in user visiting signup page - should redirect to exchange - await page.goto("/signup"); - await expect(page).toHaveURL("/exchange"); + await page.goto("/signup", { waitUntil: "networkidle" }); + await expect(page).toHaveURL("/exchange", { timeout: 10000 }); // Test signup via direct URL (new session) await clearAuth(page); @@ -174,7 +173,7 @@ test.describe("Login", () => { const inviteCode = await createInvite(request); // Register the test user via backend API - await request.post(`${API_BASE}/api/auth/register`, { + await request.post(`${getBackendUrl()}/api/auth/register`, { data: { email: testEmail, password: testPassword, diff --git a/frontend/e2e/availability.spec.ts b/frontend/e2e/availability.spec.ts index 421d7fb..1e3ea78 100644 --- a/frontend/e2e/availability.spec.ts +++ b/frontend/e2e/availability.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import { getTomorrowDateStr } from "./helpers/date"; -import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth"; +import { REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth"; +import { getBackendUrl } from "./helpers/backend-url"; /** * Availability Page E2E Tests @@ -177,7 +178,7 @@ test.describe("Availability API", () => { if (authCookie) { const dateStr = getTomorrowDateStr(); - const response = await request.put(`${API_URL}/api/admin/availability`, { + const response = await request.put(`${getBackendUrl()}/api/admin/availability`, { headers: { Cookie: `auth_token=${authCookie.value}`, "Content-Type": "application/json", @@ -203,7 +204,7 @@ test.describe("Availability API", () => { if (regularAuthCookie) { const dateStr = getTomorrowDateStr(); const response = await request.get( - `${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`, + `${getBackendUrl()}/api/admin/availability?from=${dateStr}&to=${dateStr}`, { headers: { Cookie: `auth_token=${regularAuthCookie.value}`, diff --git a/frontend/e2e/exchange.spec.ts b/frontend/e2e/exchange.spec.ts index 619913d..28bf323 100644 --- a/frontend/e2e/exchange.spec.ts +++ b/frontend/e2e/exchange.spec.ts @@ -1,6 +1,7 @@ import { test, expect, Page } from "@playwright/test"; import { getTomorrowDateStr } from "./helpers/date"; -import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth"; +import { REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth"; +import { getBackendUrl } from "./helpers/backend-url"; /** * Exchange Page E2E Tests @@ -17,7 +18,7 @@ async function setAvailability(page: Page, dateStr: string) { throw new Error("No auth cookie found when trying to set availability"); } - const response = await page.request.put(`${API_URL}/api/admin/availability`, { + const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, { headers: { Cookie: `auth_token=${authCookie.value}`, "Content-Type": "application/json", @@ -287,7 +288,7 @@ test.describe("Exchange API", () => { let authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - const priceResponse = await request.get(`${API_URL}/api/exchange/price`, { + const priceResponse = await request.get(`${getBackendUrl()}/api/exchange/price`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, @@ -299,7 +300,7 @@ test.describe("Exchange API", () => { expect(priceData.config.eur_max).toBeDefined(); // Test regular user can get trades - const tradesResponse = await request.get(`${API_URL}/api/trades`, { + const tradesResponse = await request.get(`${getBackendUrl()}/api/trades`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, @@ -316,7 +317,7 @@ test.describe("Exchange API", () => { authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - const adminPriceResponse = await request.get(`${API_URL}/api/exchange/price`, { + const adminPriceResponse = await request.get(`${getBackendUrl()}/api/exchange/price`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, @@ -324,11 +325,14 @@ test.describe("Exchange API", () => { expect(adminPriceResponse.status()).toBe(403); // Test admin can get upcoming trades - const adminTradesResponse = await request.get(`${API_URL}/api/admin/trades/upcoming`, { - headers: { - Cookie: `auth_token=${authCookie.value}`, - }, - }); + const adminTradesResponse = await request.get( + `${getBackendUrl()}/api/admin/trades/upcoming`, + { + headers: { + Cookie: `auth_token=${authCookie.value}`, + }, + } + ); expect(adminTradesResponse.status()).toBe(200); const adminTradesData = await adminTradesResponse.json(); expect(Array.isArray(adminTradesData)).toBe(true); diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts index 7b0a4e9..a3b5cdf 100644 --- a/frontend/e2e/helpers/auth.ts +++ b/frontend/e2e/helpers/auth.ts @@ -4,9 +4,12 @@ import { Page } from "@playwright/test"; * Auth helpers for e2e tests. */ -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; +import { getBackendUrl } from "./backend-url"; -export { API_URL }; +// Use dynamic backend URL based on worker index +// Note: API_URL is evaluated at module load time, so it may use default value +// For dynamic per-test URLs, use getBackendUrl() directly +export const getApiUrl = () => getBackendUrl(); export function getRequiredEnv(name: string): string { const value = process.env[name]; @@ -30,23 +33,10 @@ export async function clearAuth(page: Page) { await page.context().clearCookies(); } -export async function setEnglishLanguage(page: Page) { - // Set English language in localStorage - await page.evaluate(() => { - window.localStorage.setItem("arbret-locale", "en"); - }); -} - export async function loginUser(page: Page, email: string, password: string) { await page.goto("/login"); - // Set language after navigation to ensure localStorage is available - await setEnglishLanguage(page); - // Reload to apply language setting - await page.reload(); await page.fill('input[type="email"]', email); await page.fill('input[type="password"]', password); await page.click('button[type="submit"]'); await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }); - // Set language again after navigation to new page - await setEnglishLanguage(page); } diff --git a/frontend/e2e/helpers/backend-url.ts b/frontend/e2e/helpers/backend-url.ts new file mode 100644 index 0000000..6d89184 --- /dev/null +++ b/frontend/e2e/helpers/backend-url.ts @@ -0,0 +1,22 @@ +/** + * Helper to determine which backend URL to use based on Playwright worker index. + * Each worker gets its own backend instance and database. + */ + +/** + * Get the backend URL for the current Playwright worker. + * Uses NEXT_PUBLIC_API_URL which is set in setup.ts based on worker index. + * Falls back to environment variable or default port 8001. + */ +export function getBackendUrl(): string { + // NEXT_PUBLIC_API_URL is set in setup.ts based on worker index + return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8001"; +} + +/** + * Get the API URL for the current worker. + * This is the same as getBackendUrl but with a clearer name. + */ +export function getApiUrl(): string { + return getBackendUrl(); +} diff --git a/frontend/e2e/helpers/reset-db.ts b/frontend/e2e/helpers/reset-db.ts new file mode 100644 index 0000000..eb9894f --- /dev/null +++ b/frontend/e2e/helpers/reset-db.ts @@ -0,0 +1,21 @@ +/** + * Helper to reset the database before each test. + * Calls the /api/test/reset endpoint on the worker's backend. + */ + +import { APIRequestContext } from "@playwright/test"; +import { getBackendUrl } from "./backend-url"; + +/** + * Reset the database for the current worker. + * Truncates all tables and re-seeds base data. + */ +export async function resetDatabase(request: APIRequestContext): Promise { + const backendUrl = getBackendUrl(); + const response = await request.post(`${backendUrl}/api/test/reset`); + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Failed to reset database: ${response.status()} - ${text}`); + } +} diff --git a/frontend/e2e/helpers/setup.ts b/frontend/e2e/helpers/setup.ts index 0dfec92..bf354ab 100644 --- a/frontend/e2e/helpers/setup.ts +++ b/frontend/e2e/helpers/setup.ts @@ -1,11 +1,35 @@ import { test } from "@playwright/test"; +import { resetDatabase } from "./reset-db"; /** * Set language to English for e2e tests. * E2E tests should only test in English according to requirements. * This is applied globally via test.beforeEach in the setup file. + * + * Also sets NEXT_PUBLIC_API_URL based on worker index so each worker + * connects to its own backend instance, and resets the database before + * each test for isolation. */ -test.beforeEach(async ({ context }) => { +test.beforeEach(async ({ context, request }, testInfo) => { + // Set API URL based on worker index + // Each worker gets its own backend (port 8001 + workerIndex) + const workerIndex = testInfo.workerIndex; + const basePort = parseInt(process.env.E2E_PORT_START || "8001", 10); + const backendPort = basePort + workerIndex; + const backendUrl = `http://localhost:${backendPort}`; + + // Set environment variable for this test run + process.env.NEXT_PUBLIC_API_URL = backendUrl; + + // Reset database before each test for isolation + try { + await resetDatabase(request); + } catch (error) { + // If reset fails, log but don't fail the test + // This allows tests to run even if reset endpoint is unavailable + console.warn(`Failed to reset database: ${error}`); + } + // Add init script to set English language before any page loads // This must be called before any page.goto() calls await context.addInitScript(() => { diff --git a/frontend/e2e/permissions.spec.ts b/frontend/e2e/permissions.spec.ts index 256ae11..c6d0de8 100644 --- a/frontend/e2e/permissions.spec.ts +++ b/frontend/e2e/permissions.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from "@playwright/test"; +import { getBackendUrl } from "./helpers/backend-url"; /** * Permission-based E2E tests @@ -10,8 +11,6 @@ import { test, expect, Page } from "@playwright/test"; * 4. API calls respect permission boundaries */ -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; - // Test credentials - must match what's seeded in the database via seed.py // These come from environment variables DEV_USER_EMAIL/PASSWORD and DEV_ADMIN_EMAIL/PASSWORD // Tests will fail fast if these are not set @@ -155,7 +154,7 @@ test.describe("Permission Boundary via API", () => { let authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - const response = await request.get(`${API_URL}/api/admin/trades/upcoming`, { + const response = await request.get(`${getBackendUrl()}/api/admin/trades/upcoming`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, @@ -170,7 +169,7 @@ test.describe("Permission Boundary via API", () => { authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - const response = await request.get(`${API_URL}/api/exchange/price`, { + const response = await request.get(`${getBackendUrl()}/api/exchange/price`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, diff --git a/frontend/e2e/profile.spec.ts b/frontend/e2e/profile.spec.ts index a2c1660..4bf20e3 100644 --- a/frontend/e2e/profile.spec.ts +++ b/frontend/e2e/profile.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from "@playwright/test"; +import { getBackendUrl } from "./helpers/backend-url"; /** * Profile E2E tests @@ -10,8 +11,6 @@ import { test, expect, Page } from "@playwright/test"; * 4. Validation works as expected */ -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; - // Test credentials - must match what's seeded in the database via seed.py function getRequiredEnv(name: string): string { const value = process.env[name]; @@ -54,7 +53,7 @@ async function clearProfileData(page: Page) { const authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - await page.request.put(`${API_URL}/api/profile`, { + await page.request.put(`${getBackendUrl()}/api/profile`, { headers: { Cookie: `auth_token=${authCookie.value}`, "Content-Type": "application/json", @@ -254,7 +253,7 @@ test.describe("Profile - Admin User Access", () => { const authCookie = cookies.find((c) => c.name === "auth_token"); if (authCookie) { - const response = await request.get(`${API_URL}/api/profile`, { + const response = await request.get(`${getBackendUrl()}/api/profile`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, @@ -278,7 +277,7 @@ test.describe("Profile - Unauthenticated Access", () => { await expect(page).toHaveURL("/login"); // API requires authentication - const response = await request.get(`${API_URL}/api/profile`); + const response = await request.get(`${getBackendUrl()}/api/profile`); expect(response.status()).toBe(401); }); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index ebc1a25..88362d2 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -2,12 +2,13 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", - // Run tests sequentially to avoid database conflicts - workers: 1, - // Ensure tests within a file run in order - fullyParallel: false, - // Test timeout (per test) - timeout: 10000, + // Run tests in parallel with multiple workers + // Each worker gets its own database and backend instance + workers: 8, + // Tests can run in parallel now that each worker has isolated database + fullyParallel: true, + // Test timeout (per test) - increased for e2e tests with database resets + timeout: 30000, webServer: { command: "npm run dev", url: "http://localhost:3000", diff --git a/scripts/e2e.sh b/scripts/e2e.sh index b1d1b90..f8d6015 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -3,13 +3,15 @@ set -e cd "$(dirname "$0")/.." -# E2E tests use a separate database and port to allow parallel execution with backend tests -E2E_PORT=${E2E_PORT:-8001} -E2E_DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e" +# E2E tests use separate databases and ports per worker for parallel execution +E2E_PORT_START=${E2E_PORT_START:-8001} +NUM_WORKERS=${NUM_WORKERS:-8} # Default to 8 workers, can be overridden # Cleanup function to kill background processes cleanup() { - kill $BACKEND_PID 2>/dev/null || true + for pid in "${BACKEND_PIDS[@]}"; do + kill $pid 2>/dev/null || true + done } # Ensure cleanup runs on exit (normal, error, or interrupt) @@ -22,42 +24,76 @@ if [ -f .env ]; then set +a fi -# Kill any existing e2e backend (on our specific port) -pkill -f "uvicorn main:app --port $E2E_PORT" 2>/dev/null || true +# Kill any existing e2e backends +echo "Cleaning up existing e2e backends..." +for i in $(seq 0 $((NUM_WORKERS - 1))); do + PORT=$((E2E_PORT_START + i)) + pkill -f "uvicorn main:app --port $PORT" 2>/dev/null || true +done sleep 1 -# Seed the e2e database with roles and test users +# Seed all worker databases +echo "Seeding worker databases..." cd backend -echo "Seeding e2e database..." -DATABASE_URL="$E2E_DATABASE_URL" uv run python seed.py +BACKEND_PIDS=() +for i in $(seq 0 $((NUM_WORKERS - 1))); do + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e_worker$i" + echo " Seeding database arbret_e2e_worker$i..." + DATABASE_URL="$DATABASE_URL" E2E_MODE=1 uv run python seed.py & +done +wait cd .. -# Start backend for e2e tests (uses e2e database and separate port) +# Start backends for each worker +echo "Starting $NUM_WORKERS backend instances..." cd backend -DATABASE_URL="$E2E_DATABASE_URL" uv run uvicorn main:app --port $E2E_PORT --log-level warning & -BACKEND_PID=$! +for i in $(seq 0 $((NUM_WORKERS - 1))); do + PORT=$((E2E_PORT_START + i)) + DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e_worker$i" + echo " Starting backend on port $PORT for worker $i..." + DATABASE_URL="$DATABASE_URL" E2E_MODE=1 uv run uvicorn main:app --port $PORT --log-level warning & + BACKEND_PIDS+=($!) +done cd .. -# Wait for backend -sleep 2 +# Wait for all backends to be ready +echo "Waiting for backends to be ready..." +for i in $(seq 0 $((NUM_WORKERS - 1))); do + PORT=$((E2E_PORT_START + i)) + MAX_ATTEMPTS=30 + ATTEMPT=0 + while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + if curl -s "http://localhost:$PORT/docs" > /dev/null 2>&1; then + echo " Backend on port $PORT is ready" + break + fi + ATTEMPT=$((ATTEMPT + 1)) + sleep 0.5 + done + if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then + echo " ERROR: Backend on port $PORT failed to start" + exit 1 + fi +done -# Generate API types from OpenAPI schema (using e2e backend) +# Generate API types from first e2e backend (they should all have same schema) echo "Generating API types from e2e backend..." cd frontend -npx openapi-typescript "http://localhost:$E2E_PORT/openapi.json" -o app/generated/api.ts +npx openapi-typescript "http://localhost:$E2E_PORT_START/openapi.json" -o app/generated/api.ts cd .. # Run tests with e2e-specific backend URL -# The frontend will connect to our e2e backend on $E2E_PORT +# Tests will determine which backend to use based on worker index cd frontend -export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT" +export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT_START" # Default, tests override per worker +export E2E_PORT_START=$E2E_PORT_START +export NUM_WORKERS=$NUM_WORKERS if [ -n "$1" ]; then - NODE_NO_WARNINGS=1 npx playwright test "$1" + NODE_NO_WARNINGS=1 npx playwright test --workers=$NUM_WORKERS "$1" else - NODE_NO_WARNINGS=1 npm run test:e2e + NODE_NO_WARNINGS=1 npx playwright test --workers=$NUM_WORKERS fi EXIT_CODE=$? # Cleanup is handled by trap EXIT exit $EXIT_CODE -