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:
JoeBot
2026-04-05 12:40:05 -04:00
parent 629d112850
commit dfd1bccb89
20 changed files with 1751 additions and 0 deletions

108
tests/fixtures/auth.fixture.ts vendored Normal file
View 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
View 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
View 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
View 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;