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

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