working
This commit is contained in:
parent
c0999370c6
commit
4e1a339432
17 changed files with 393 additions and 91 deletions
35
Makefile
35
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
49
backend/routes/test.py
Normal 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
90
backend/seed_e2e.py
Normal 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()
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
22
frontend/e2e/helpers/backend-url.ts
Normal file
22
frontend/e2e/helpers/backend-url.ts
Normal 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();
|
||||
}
|
||||
21
frontend/e2e/helpers/reset-db.ts
Normal file
21
frontend/e2e/helpers/reset-db.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue