This commit is contained in:
counterweight 2025-12-26 19:21:34 +01:00
parent c0999370c6
commit 4e1a339432
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
17 changed files with 393 additions and 91 deletions

View file

@ -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

View file

@ -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:

49
backend/routes/test.py Normal file
View file

@ -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)}

90
backend/seed_e2e.py Normal file
View file

@ -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()

View file

@ -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;

View file

@ -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");
});
});

View file

@ -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<string> {
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,

View file

@ -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}`,

View file

@ -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);

View file

@ -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);
}

View file

@ -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();
}

View file

@ -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<void> {
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}`);
}
}

View file

@ -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(() => {

View file

@ -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}`,
},

View file

@ -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);
});
});

View file

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

View file

@ -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