feat: add Playwright E2E and API regression test suite
Production-ready test infrastructure with Page Object Model pattern, reusable fixtures for auth/DB/test-data, and example tests covering login flow, dashboard, accounts CRUD API, and visual regression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
108
tests/fixtures/auth.fixture.ts
vendored
Normal file
108
tests/fixtures/auth.fixture.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Authentication fixture for E2E tests.
|
||||
*
|
||||
* Provides helpers to authenticate via the API and manage JWT tokens.
|
||||
* The auth.setup.ts project uses this to create persistent auth state
|
||||
* that other tests reuse (avoiding login in every test).
|
||||
*/
|
||||
|
||||
import { type APIRequestContext, type BrowserContext } from '@playwright/test';
|
||||
import { TEST_USERS } from './test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
user: Record<string, unknown>;
|
||||
organizations: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login via the API and return tokens.
|
||||
* Uses Playwright's request context (no browser needed).
|
||||
*/
|
||||
export async function apiLogin(
|
||||
request: APIRequestContext,
|
||||
credentials: { email: string; password: string } = TEST_USERS.treasurer,
|
||||
): Promise<AuthTokens> {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Login failed (${response.status()}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific organization after login.
|
||||
*/
|
||||
export async function apiSwitchOrg(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
organizationId: string,
|
||||
): Promise<AuthTokens> {
|
||||
const response = await request.post(`${API_BASE}/auth/switch-org`, {
|
||||
data: { organizationId },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Switch org failed (${response.status()}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authenticated API request context with a Bearer token.
|
||||
* Useful for API-level tests that don't need a browser.
|
||||
*/
|
||||
export function authHeaders(accessToken: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject auth state into a browser context's localStorage.
|
||||
* Matches the frontend's Zustand authStore shape.
|
||||
*/
|
||||
export async function injectAuthState(
|
||||
context: BrowserContext,
|
||||
tokens: AuthTokens,
|
||||
orgId?: string,
|
||||
): Promise<void> {
|
||||
const baseURL = process.env.BASE_URL || 'http://localhost';
|
||||
|
||||
// The frontend uses Zustand with persist middleware in localStorage
|
||||
// under key 'auth-storage'. We inject it so the frontend thinks
|
||||
// the user is already logged in.
|
||||
const selectedOrg = orgId
|
||||
? tokens.organizations.find((o: any) => o.id === orgId)
|
||||
: tokens.organizations[0];
|
||||
|
||||
const authState = {
|
||||
state: {
|
||||
token: tokens.accessToken,
|
||||
user: tokens.user,
|
||||
organizations: tokens.organizations,
|
||||
currentOrg: selectedOrg
|
||||
? { id: (selectedOrg as any).id, name: (selectedOrg as any).name, role: (selectedOrg as any).role }
|
||||
: null,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
|
||||
await context.addInitScript((authData) => {
|
||||
window.localStorage.setItem('auth-storage', JSON.stringify(authData));
|
||||
}, authState);
|
||||
}
|
||||
87
tests/fixtures/base.fixture.ts
vendored
Normal file
87
tests/fixtures/base.fixture.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Base Playwright fixture that combines auth + DB helpers.
|
||||
*
|
||||
* Extends the default `test` object so every test file can
|
||||
* import from here and get typed access to fixtures.
|
||||
*
|
||||
* Usage:
|
||||
* import { test, expect } from '../fixtures/base.fixture';
|
||||
* test('my test', async ({ authedPage, apiContext }) => { ... });
|
||||
*/
|
||||
|
||||
import { test as base, type Page, type APIRequestContext } from '@playwright/test';
|
||||
import { apiLogin, apiSwitchOrg, authHeaders, type AuthTokens } from './auth.fixture';
|
||||
import { testDb } from './db.fixture';
|
||||
import { TEST_USERS } from './test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
type TestFixtures = {
|
||||
/** Pre-authenticated API request context with Bearer token */
|
||||
authedRequest: APIRequestContext & { _tokens: AuthTokens };
|
||||
/** Access token string for manual header construction */
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
/** Shared database connection (one per worker) */
|
||||
db: typeof testDb;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
/**
|
||||
* Worker-scoped database connection.
|
||||
* Connects once per worker, disconnects when the worker exits.
|
||||
*/
|
||||
db: [
|
||||
async ({}, use) => {
|
||||
await testDb.connect();
|
||||
await use(testDb);
|
||||
await testDb.disconnect();
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Per-test authenticated API request context.
|
||||
* Logs in as the default test user and attaches the Bearer token.
|
||||
*/
|
||||
authedRequest: async ({ playwright }, use) => {
|
||||
const requestContext = await playwright.request.newContext({
|
||||
baseURL: API_BASE,
|
||||
});
|
||||
|
||||
const tokens = await apiLogin(requestContext, TEST_USERS.treasurer);
|
||||
|
||||
// If user belongs to orgs, switch to the first one
|
||||
let finalTokens = tokens;
|
||||
if (tokens.organizations?.length > 0) {
|
||||
const orgId = (tokens.organizations[0] as any).id;
|
||||
try {
|
||||
finalTokens = await apiSwitchOrg(requestContext, tokens.accessToken, orgId);
|
||||
} catch {
|
||||
// switch-org may not be needed if token already scoped
|
||||
finalTokens = tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new context with the auth header baked in
|
||||
const authedContext = await playwright.request.newContext({
|
||||
baseURL: API_BASE,
|
||||
extraHTTPHeaders: authHeaders(finalTokens.accessToken),
|
||||
});
|
||||
|
||||
// Attach tokens for tests that need them
|
||||
(authedContext as any)._tokens = finalTokens;
|
||||
|
||||
await use(authedContext as any);
|
||||
await authedContext.dispose();
|
||||
await requestContext.dispose();
|
||||
},
|
||||
|
||||
accessToken: async ({ authedRequest }, use) => {
|
||||
await use((authedRequest as any)._tokens.accessToken);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
124
tests/fixtures/db.fixture.ts
vendored
Normal file
124
tests/fixtures/db.fixture.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Database fixture for E2E tests.
|
||||
*
|
||||
* Provides helpers to seed and clean up test data in Postgres.
|
||||
* Uses the `pg` driver directly (same driver the backend uses)
|
||||
* to avoid coupling tests to the backend's TypeORM setup.
|
||||
*
|
||||
* Usage in tests:
|
||||
* import { testDb } from '../fixtures/db.fixture';
|
||||
* test.beforeAll(async () => { await testDb.connect(); });
|
||||
* test.afterAll(async () => { await testDb.cleanup(); await testDb.disconnect(); });
|
||||
*/
|
||||
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { TEST_PREFIX } from './test-data';
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DB_URL ||
|
||||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
||||
|
||||
class TestDatabase {
|
||||
private pool: Pool | null = null;
|
||||
|
||||
/** Connect to the test database */
|
||||
async connect(): Promise<void> {
|
||||
this.pool = new Pool({ connectionString: TEST_DB_URL, max: 5 });
|
||||
// Verify connectivity
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('SELECT 1');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a client from the pool */
|
||||
async getClient(): Promise<PoolClient> {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
return this.pool.connect();
|
||||
}
|
||||
|
||||
/** Execute a query */
|
||||
async query(sql: string, params?: unknown[]) {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
return this.pool.query(sql, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all test-created data.
|
||||
* Removes rows that match the TEST_PREFIX in name/description/memo fields.
|
||||
* This runs within a transaction so it's atomic.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (!this.pool) return;
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Clean up test data from tenant schemas.
|
||||
// We look for the e2e test org schema if it exists.
|
||||
const schemas = await client.query(
|
||||
`SELECT schema_name FROM shared.organizations
|
||||
WHERE name LIKE '${TEST_PREFIX}%' OR schema_name = 'e2e_test_hoa'`,
|
||||
);
|
||||
|
||||
for (const row of schemas.rows) {
|
||||
const schema = row.schema_name;
|
||||
// Delete test journal entries, accounts, etc. in tenant schema
|
||||
await client.query(
|
||||
`DELETE FROM "${schema}".journal_entries WHERE memo LIKE $1`,
|
||||
[`${TEST_PREFIX}%`],
|
||||
).catch(() => {}); // Table may not exist
|
||||
await client.query(
|
||||
`DELETE FROM "${schema}".accounts WHERE name LIKE $1`,
|
||||
[`${TEST_PREFIX}%`],
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// Clean up test users from shared schema
|
||||
await client.query(
|
||||
`DELETE FROM shared.users WHERE email LIKE 'e2e-%@test.hoaledgeriq.com'`,
|
||||
).catch(() => {});
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Test cleanup failed:', err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimum data needed for authenticated test flows:
|
||||
* - A test organization (if not exists)
|
||||
* - A test user with known credentials (if not exists)
|
||||
* - Associates user to org with the given role
|
||||
*
|
||||
* This is idempotent — safe to call multiple times.
|
||||
*/
|
||||
async seedTestUser(user: {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
|
||||
// This is done via API calls in auth.setup.ts instead of direct SQL,
|
||||
// because the backend handles password hashing, schema creation, etc.
|
||||
// This method is available for advanced scenarios that need direct DB access.
|
||||
console.log(`[TestDB] Seed user ${user.email} — use API-based seeding in auth.setup.ts`);
|
||||
}
|
||||
|
||||
/** Disconnect from the database */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const testDb = new TestDatabase();
|
||||
48
tests/fixtures/test-data.ts
vendored
Normal file
48
tests/fixtures/test-data.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared test data constants for E2E and API tests.
|
||||
*
|
||||
* Credentials match what the DB seed fixture creates.
|
||||
* Money values are in cents (matching the backend convention).
|
||||
*/
|
||||
|
||||
export const TEST_USERS = {
|
||||
treasurer: {
|
||||
email: process.env.TEST_USER_EMAIL || 'e2e-treasurer@test.hoaledgeriq.com',
|
||||
password: process.env.TEST_USER_PASSWORD || 'TestPass123!',
|
||||
role: 'treasurer',
|
||||
fullName: 'E2E Test Treasurer',
|
||||
},
|
||||
viewer: {
|
||||
email: 'e2e-viewer@test.hoaledgeriq.com',
|
||||
password: 'TestPass123!',
|
||||
role: 'viewer',
|
||||
fullName: 'E2E Test Viewer',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TEST_ORG = {
|
||||
name: 'E2E Test HOA',
|
||||
schemaName: 'e2e_test_hoa',
|
||||
} as const;
|
||||
|
||||
/** Sample account used for CRUD tests */
|
||||
export const SAMPLE_ACCOUNT = {
|
||||
name: 'E2E Operating Checking',
|
||||
accountType: 'asset',
|
||||
fundType: 'operating',
|
||||
number: '1010',
|
||||
description: 'E2E test operating account',
|
||||
} as const;
|
||||
|
||||
/** Sample journal entry for write-path tests */
|
||||
export const SAMPLE_JOURNAL_ENTRY = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
memo: 'E2E test journal entry',
|
||||
lines: [
|
||||
{ accountId: '', debit: 100_00, credit: 0 },
|
||||
{ accountId: '', credit: 100_00, debit: 0 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Unique prefix for test-created data — makes cleanup queries simple */
|
||||
export const TEST_PREFIX = 'E2E_' as const;
|
||||
Reference in New Issue
Block a user