working
This commit is contained in:
parent
c0999370c6
commit
4e1a339432
17 changed files with 393 additions and 91 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue