1 Commits

Author SHA1 Message Date
JoeBot
dfd1bccb89 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>
2026-04-05 12:40:05 -04:00
20 changed files with 1751 additions and 0 deletions

28
.env.test.example Normal file
View File

@@ -0,0 +1,28 @@
# ─── Playwright E2E Test Environment ────────────────────────────────
# Copy to .env.test and fill in values for your local or CI setup.
# Base URL of the running application (nginx proxy)
# Local dev: http://localhost (Docker Compose nginx on port 80)
# Production: https://your-production-domain.com
BASE_URL=http://localhost
# ─── Test Database ──────────────────────────────────────────────────
# Direct Postgres connection for test data seeding/cleanup.
# Use the SAME database as Docker Compose postgres service.
# WARNING: Tests will create/delete data — never point at production.
TEST_DB_URL=postgresql://hoafinance:change_me@localhost:5432/hoafinance
# ─── Test User Credentials ──────────────────────────────────────────
# Pre-seeded user for authenticated test flows.
# The seed script (tests/fixtures/db.fixture.ts) creates this user.
TEST_USER_EMAIL=e2e-treasurer@test.hoaledgeriq.com
TEST_USER_PASSWORD=TestPass123!
TEST_USER_ROLE=treasurer
# ─── API Base URL ───────────────────────────────────────────────────
# Backend API base (through nginx). Usually same as BASE_URL + /api
API_BASE_URL=http://localhost/api
# ─── CI Settings ────────────────────────────────────────────────────
# CI=true is typically set by CI providers automatically.
# CI=true

10
.gitignore vendored
View File

@@ -44,3 +44,13 @@ coverage/
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
# Playwright
/test-results/
/playwright-report/
/blob-report/
tests/.auth/
*-snapshots/
# Test environment
.env.test

349
TESTING_CONVENTIONS.md Normal file
View File

@@ -0,0 +1,349 @@
# Testing Conventions — HOA LedgerIQ E2E & API Tests
This document is the single source of truth for writing, organizing, and running Playwright-based E2E and API regression tests in this project.
---
## Architecture
| Component | Technology | Port |
|-----------|-----------|------|
| Reverse proxy | nginx | :80 |
| Backend API | NestJS 10 | :3000 (internal) |
| Frontend | React 18 + Vite | :5173 (internal) |
| Database | PostgreSQL 15 | :5432 |
| Cache | Redis 7 | :6379 |
| Test runner | Playwright | host |
Tests run on the **host machine** against the app running in **Docker Compose**. The `BASE_URL` defaults to `http://localhost` (nginx).
---
## Folder Structure
```
tests/
├── .auth/ # Stored auth state (gitignored)
│ └── user.json # Browser state from auth.setup.ts
├── fixtures/
│ ├── auth.fixture.ts # API login helpers, token management
│ ├── base.fixture.ts # Extended test object with typed fixtures
│ ├── db.fixture.ts # Postgres seed/cleanup via pg driver
│ └── test-data.ts # Shared constants (users, sample data)
├── page-objects/
│ ├── index.ts # Re-exports all page objects
│ ├── BasePage.ts # Abstract base with shared helpers
│ ├── LoginPage.ts # /login page
│ ├── DashboardPage.ts # /dashboard page
│ └── AccountsPage.ts # /accounts page
├── e2e/ # Browser-based end-to-end tests
│ ├── auth.spec.ts # Login/logout UI flows
│ ├── dashboard.spec.ts # Dashboard load + navigation
│ └── visual.spec.ts # Screenshot regression tests
├── api/ # API-only tests (no browser)
│ ├── auth.api.spec.ts # /api/auth/* endpoints
│ └── accounts.api.spec.ts # /api/accounts/* CRUD
└── auth.setup.ts # One-time auth setup project
```
---
## Naming Conventions
| What | Convention | Example |
|------|-----------|---------|
| E2E test files | `tests/e2e/<feature>.spec.ts` | `auth.spec.ts` |
| API test files | `tests/api/<resource>.api.spec.ts` | `accounts.api.spec.ts` |
| Page objects | `tests/page-objects/<PageName>.ts` | `LoginPage.ts` |
| Fixtures | `tests/fixtures/<purpose>.fixture.ts` | `db.fixture.ts` |
| Test data | `tests/fixtures/test-data.ts` | single file |
| Snapshot baselines | auto-generated in `*-snapshots/` dirs | `login-page.png` |
### Test descriptions
Use `test.describe('Feature or Endpoint')` and `test('should <behavior>')`:
```ts
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
// ...
});
});
```
---
## How to Write New Tests
### 1. E2E (browser) test
```ts
// tests/e2e/invoices.spec.ts
import { test, expect } from '../fixtures/base.fixture';
import { InvoicesPage } from '../page-objects';
test.describe('Invoices', () => {
let invoicesPage: InvoicesPage;
test.beforeEach(async ({ page }) => {
invoicesPage = new InvoicesPage(page);
await invoicesPage.goto();
});
test('should display invoice list', async () => {
await invoicesPage.assertOnPage();
// ... assertions
});
});
```
### 2. API test
```ts
// tests/api/payments.api.spec.ts
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const switched = await apiSwitchOrg(request, tokens.accessToken, (tokens.organizations[0] as any).id);
accessToken = switched.accessToken;
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/payments', () => {
test('should return payments list', async ({ request }) => {
const response = await request.get(`${API_BASE}/payments`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
});
});
```
### 3. Visual regression test
```ts
test('invoices page should match baseline', async ({ page }) => {
await page.goto('/invoices');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500); // Let animations settle
await expect(page).toHaveScreenshot('invoices-page.png', {
fullPage: true,
mask: [page.locator('time')], // Mask dynamic dates
});
});
```
Update baselines: `npx playwright test --update-snapshots`
---
## How to Add New Page Objects
1. Create `tests/page-objects/MyPage.ts`:
```ts
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class MyPage extends BasePage {
readonly path = '/my-path';
// Locators — prefer role/label selectors over CSS
get heading() {
return this.page.getByRole('heading', { name: /my page/i });
}
get createButton() {
return this.page.getByRole('button', { name: /create/i });
}
// Actions
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
await expect(this.heading).toBeVisible();
}
async createItem(name: string): Promise<void> {
await this.createButton.click();
await this.page.getByLabel(/name/i).fill(name);
await this.page.getByRole('button', { name: /save/i }).click();
}
}
```
2. Export from `tests/page-objects/index.ts`:
```ts
export { MyPage } from './MyPage';
```
### Page object rules
- Extend `BasePage` and set `readonly path`
- Override `waitForReady()` for page-specific loading
- Use **role/label locators** (not CSS selectors): `getByRole()`, `getByLabel()`, `getByText()`
- Expose **locators as getters** and **actions as methods**
- Keep assertions in test files, not page objects (except `assertOnPage()`)
---
## Authentication in Tests
### Pre-authenticated tests (default)
Most tests use stored auth state from `auth.setup.ts`. This runs once via the `auth-setup` Playwright project and saves browser state to `tests/.auth/user.json`.
Tests automatically get this state via `storageState` in `playwright.config.ts`.
### Unauthenticated tests
For testing the login flow itself, opt out:
```ts
test.use({ storageState: { cookies: [], origins: [] } });
```
### API tests
Use the `apiLogin()` and `authHeaders()` helpers:
```ts
import { apiLogin, authHeaders } from '../fixtures/auth.fixture';
const tokens = await apiLogin(request, TEST_USERS.treasurer);
const response = await request.get(url, {
headers: authHeaders(tokens.accessToken),
});
```
---
## Database Seeding & Cleanup
### When to use direct DB access
- Verifying backend wrote correct data
- Seeding complex state that's hard to create via API
- Cleanup after tests
### How
```ts
import { test } from '../fixtures/base.fixture';
test('should verify data', async ({ db }) => {
const result = await db.query('SELECT * FROM schema.table WHERE ...');
expect(result.rows.length).toBeGreaterThan(0);
});
```
### Cleanup convention
- Prefix all test-created data with `E2E_` (use `TEST_PREFIX` from test-data.ts)
- The `db.cleanup()` method deletes rows matching this prefix
- Call `db.cleanup()` in `test.afterAll` for write-path tests
---
## Running Tests
### Prerequisites
1. Docker Compose services running: `docker-compose up -d`
2. Test user seeded in the database (use the backend seed script or create manually)
3. Environment configured: `cp .env.test.example .env.test` and fill in values
### Commands
```bash
# Install Playwright (first time)
npx playwright install --with-deps
# Run all tests
npx playwright test
# Run only E2E tests
npx playwright test tests/e2e/
# Run only API tests
npx playwright test --project=api
# Run in specific browser
npx playwright test --project=chromium
# Run in headed mode (see the browser)
npx playwright test --headed
# Run a single test file
npx playwright test tests/e2e/auth.spec.ts
# Debug mode (step through tests)
npx playwright test --debug
# Update visual regression baselines
npx playwright test tests/e2e/visual.spec.ts --update-snapshots
# View HTML report
npx playwright show-report
# Run against production
BASE_URL=https://your-prod-domain.com npx playwright test --project=api
```
### npm scripts (from project root)
```bash
npm run test:e2e # All Playwright tests
npm run test:e2e:chromium # Chromium only
npm run test:e2e:api # API tests only
npm run test:e2e:headed # Headed mode
npm run test:e2e:debug # Debug mode
```
---
## Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `BASE_URL` | `http://localhost` | App URL (nginx) |
| `API_BASE_URL` | `http://localhost/api` | Backend API base |
| `TEST_DB_URL` | `postgresql://hoafinance:change_me@localhost:5432/hoafinance` | Direct Postgres for seeding |
| `TEST_USER_EMAIL` | `e2e-treasurer@test.hoaledgeriq.com` | Test user email |
| `TEST_USER_PASSWORD` | `TestPass123!` | Test user password |
| `CI` | — | Set by CI providers; enables retries, single worker |
---
## Style Rules
1. **Import `test` from `../fixtures/base.fixture`** for tests needing DB or auth fixtures. Import from `@playwright/test` for basic tests.
2. **One `test.describe` per feature or endpoint** per file.
3. **No `page.waitForTimeout()` except in visual tests** — use `waitForLoadState`, `waitForURL`, or `waitForResponse` instead.
4. **No hardcoded URLs** — use `BASE_URL`, `API_BASE`, or page object paths.
5. **No test interdependencies** — each test should work in isolation (use `test.beforeEach` for setup).
6. **Clean up after write tests** — use `TEST_PREFIX` and `db.cleanup()`.
7. **API tests go in `tests/api/`**, E2E tests in `tests/e2e/`** — don't mix.
8. **Locators**: prefer `getByRole` > `getByLabel` > `getByText` > `getByTestId` > CSS selectors.
---
## Adding Tests for a New Feature (Quick Checklist)
- [ ] Create page object in `tests/page-objects/` if it's a new page
- [ ] Export it from `tests/page-objects/index.ts`
- [ ] Create `tests/e2e/<feature>.spec.ts` for UI flows
- [ ] Create `tests/api/<resource>.api.spec.ts` for API endpoints
- [ ] Add sample data constants to `tests/fixtures/test-data.ts` if needed
- [ ] Run `npx playwright test tests/e2e/<feature>.spec.ts` to verify
- [ ] Update visual baselines if the feature changes existing pages

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "hoa-ledgeriq-tests",
"version": "1.0.0",
"private": true,
"description": "Root package for Playwright E2E & API tests",
"scripts": {
"test:e2e": "npx playwright test",
"test:e2e:chromium": "npx playwright test --project=chromium",
"test:e2e:firefox": "npx playwright test --project=firefox",
"test:e2e:webkit": "npx playwright test --project=webkit",
"test:e2e:api": "npx playwright test --project=api",
"test:e2e:headed": "npx playwright test --headed",
"test:e2e:debug": "npx playwright test --debug",
"test:e2e:ui": "npx playwright test --ui",
"test:e2e:report": "npx playwright show-report",
"test:e2e:update-snapshots": "npx playwright test --update-snapshots"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"dotenv": "^16.4.0",
"pg": "^8.13.0",
"@types/pg": "^8.11.0"
}
}

127
playwright.config.ts Normal file
View File

@@ -0,0 +1,127 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
/**
* Playwright configuration for HOA LedgerIQ E2E + API regression tests.
*
* Architecture: Docker Compose (nginx :80 -> backend :3000 + frontend :5173)
* - Local dev: `docker-compose up` then `npm run test:e2e`
* - CI: starts Docker services automatically
* - Production: set BASE_URL to skip webServer start
*/
// Load test-specific env from .env.test (falls back to .env)
require('dotenv').config({ path: path.resolve(__dirname, '.env.test') });
const BASE_URL = process.env.BASE_URL || 'http://localhost';
const IS_CI = !!process.env.CI;
// Skip auto-starting services when pointing at an external URL
const isExternalTarget =
BASE_URL !== 'http://localhost' && BASE_URL !== 'http://localhost:80';
export default defineConfig({
testDir: './tests',
testMatch: ['**/*.spec.ts'],
/* Run tests in parallel where safe */
fullyParallel: true,
/* Fail CI builds if test.only was left in */
forbidOnly: IS_CI,
/* Retry on CI to handle transient failures */
retries: IS_CI ? 2 : 0,
/* Limit parallel workers on CI */
workers: IS_CI ? 1 : undefined,
/* Reporter configuration */
reporter: IS_CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'on-failure' }]],
/* Shared settings for all projects */
use: {
baseURL: BASE_URL,
/* Collect trace on first retry for debugging */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
/* Video on failure in CI */
video: IS_CI ? 'on-first-retry' : 'off',
/* Default timeout for actions (click, fill, etc.) */
actionTimeout: 10_000,
/* Navigation timeout */
navigationTimeout: 30_000,
},
/* Global test timeout */
timeout: 60_000,
/* Assertion timeout */
expect: {
timeout: 10_000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.02,
},
},
/* Browser projects */
projects: [
/* Auth setup — runs once, stores auth state for other tests */
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
/* API tests — no browser needed, runs in chromium for request context */
{
name: 'api',
testMatch: ['**/api/**/*.spec.ts'],
use: { ...devices['Desktop Chrome'] },
dependencies: [],
},
],
/* Start Docker services before tests when running locally */
...(!isExternalTarget && {
webServer: {
command: 'docker-compose up -d && sleep 5 && docker-compose exec backend echo "ready"',
url: BASE_URL,
reuseExistingServer: !IS_CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
}),
});

View File

@@ -0,0 +1,140 @@
/**
* API regression tests for the Accounts endpoint.
*
* Tests CRUD operations on /api/accounts.
* Requires authentication — logs in first to get a Bearer token.
*/
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS, TEST_PREFIX } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
// Login and switch to test org
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const orgId = (tokens.organizations[0] as any).id;
try {
const switched = await apiSwitchOrg(request, tokens.accessToken, orgId);
accessToken = switched.accessToken;
} catch {
accessToken = tokens.accessToken;
}
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/accounts', () => {
test('should return accounts list', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
test('should return 401 without auth', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`);
expect(response.status()).toBe(401);
});
test('should filter by fund type', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts?fundType=operating`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
});
test.describe('Accounts CRUD', () => {
let createdAccountId: string;
test('should create a new account', async ({ request }) => {
const response = await request.post(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Operating Checking`,
number: '1099',
accountType: 'asset',
fundType: 'operating',
description: 'E2E test account — safe to delete',
},
});
expect(response.status()).toBe(201);
const account = await response.json();
expect(account).toHaveProperty('id');
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
createdAccountId = account.id;
});
test('should get the created account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.id).toBe(createdAccountId);
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
});
test('should update the account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.put(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Updated Checking`,
description: 'Updated by E2E test',
},
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
test('should appear in accounts list', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
const accounts = await response.json();
const found = accounts.find((a: any) => a.id === createdAccountId);
expect(found).toBeTruthy();
expect(found.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
});
test.describe('GET /api/accounts/trial-balance', () => {
test('should return trial balance data', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts/trial-balance`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const data = await response.json();
// Trial balance returns an object or array with debit/credit totals
expect(data).toBeDefined();
});
});

124
tests/api/auth.api.spec.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* API regression tests for authentication endpoints.
*
* Tests the NestJS auth controller directly via HTTP.
* No browser needed — uses Playwright's request context.
*/
import { test, expect } from '@playwright/test';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toHaveProperty('accessToken');
expect(body).toHaveProperty('user');
expect(body.user).toHaveProperty('email', TEST_USERS.treasurer.email);
expect(body).toHaveProperty('organizations');
expect(Array.isArray(body.organizations)).toBe(true);
});
test('should return 401 for invalid password', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: 'WrongPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should return 401 for non-existent user', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: 'nonexistent@example.com',
password: 'AnyPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should set httpOnly refresh cookie on success', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
// Check for Set-Cookie header with the refresh token
const setCookie = response.headers()['set-cookie'] || '';
expect(setCookie).toContain('ledgeriq_rt');
expect(setCookie).toContain('HttpOnly');
});
});
test.describe('GET /api/auth/profile', () => {
test('should return 401 without auth header', async ({ request }) => {
const response = await request.get(`${API_BASE}/auth/profile`);
expect(response.status()).toBe(401);
});
test('should return user profile with valid token', async ({ request }) => {
// Login first
const loginResponse = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
const { accessToken } = await loginResponse.json();
// Fetch profile
const response = await request.get(`${API_BASE}/auth/profile`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile).toHaveProperty('email', TEST_USERS.treasurer.email);
});
});
test.describe('POST /api/auth/logout', () => {
test('should return success and clear cookie', async ({ request }) => {
// Login first to get the cookie
await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
// Logout
const response = await request.post(`${API_BASE}/auth/logout`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toEqual({ success: true });
});
});
test.describe('POST /api/auth/refresh', () => {
test('should return 400 without refresh cookie', async ({ request }) => {
// Create a fresh context without cookies
const response = await request.post(`${API_BASE}/auth/refresh`);
// Should fail — no refresh token cookie
expect([400, 401]).toContain(response.status());
});
});

50
tests/auth.setup.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Auth setup project — runs ONCE before all browser-based tests.
*
* Logs in via the UI, then saves the authenticated browser state
* (cookies + localStorage) to tests/.auth/user.json so that every
* subsequent test starts already logged in.
*
* This matches Playwright's recommended "global setup via project
* dependencies" pattern.
*/
import { test as setup, expect } from '@playwright/test';
import { TEST_USERS } from './fixtures/test-data';
import path from 'path';
const authFile = path.join(__dirname, '.auth', 'user.json');
setup('authenticate as test treasurer', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill login form — uses Mantine component labels
await page.getByLabel('Email').fill(TEST_USERS.treasurer.email);
await page.getByLabel('Password').fill(TEST_USERS.treasurer.password);
// Submit
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for redirect away from login page.
// After login, the app redirects to /select-org (multi-org) or /dashboard.
await page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
// If we land on org selection, pick the first org
if (page.url().includes('/select-org')) {
// Click the first organization card/button
const orgButton = page.getByRole('button').first();
if (await orgButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await orgButton.click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
}
// Verify we're authenticated — page should not be on /login
expect(page.url()).not.toContain('/login');
// Save authenticated state
await page.context().storageState({ path: authFile });
});

97
tests/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* E2E tests for the authentication flow.
*
* Tests login via UI (not reusing stored auth state).
* These tests use a fresh browser context per test.
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
import { TEST_USERS } from '../fixtures/test-data';
// Don't use stored auth state — we're testing the login flow itself
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Login Page', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should display login form', async () => {
await expect(loginPage.emailInput).toBeVisible();
await expect(loginPage.passwordInput).toBeVisible();
await expect(loginPage.signInButton).toBeVisible();
});
test('should show error for invalid credentials', async () => {
await loginPage.login('invalid@example.com', 'WrongPassword!');
// Wait for the error alert to appear
await loginPage.assertError(/invalid|unauthorized|failed/i);
await loginPage.assertStillOnLogin();
});
test('should show validation error for empty fields', async ({ page }) => {
// Try to submit with empty password
await loginPage.emailInput.fill('test@example.com');
await loginPage.signInButton.click();
// Should remain on login page (Mantine form validation)
await loginPage.assertStillOnLogin();
});
test('should login successfully with valid credentials', async () => {
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Should be redirected away from login
await expect(loginPage.page).not.toHaveURL(/\/login/);
});
test('should show register link', async () => {
await expect(loginPage.registerLink).toBeVisible();
});
test('should redirect unauthenticated users to login', async ({ page }) => {
// Try accessing a protected route directly
await page.goto('/dashboard');
// Should be redirected to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Logout', () => {
test('should clear auth state on logout', async ({ page }) => {
// First login
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Handle org selection if needed
if (page.url().includes('/select-org')) {
await page.getByRole('button').first().click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
// Look for logout in user menu/settings
const userMenu = page.locator('[class*="avatar"], [class*="Avatar"]').first();
if (await userMenu.isVisible({ timeout: 3_000 }).catch(() => false)) {
await userMenu.click();
}
const logoutButton = page.getByRole('button', { name: /logout|sign out/i }).first();
if (await logoutButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await logoutButton.click();
await expect(page).toHaveURL(/\/login/);
}
});
});

View File

@@ -0,0 +1,76 @@
/**
* E2E tests for the Dashboard page.
*
* Uses pre-authenticated state from auth.setup.ts.
* Verifies dashboard loads, displays data, and navigation works.
*/
import { test, expect } from '../fixtures/base.fixture';
import { DashboardPage } from '../page-objects';
test.describe('Dashboard', () => {
let dashboard: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboard = new DashboardPage(page);
await dashboard.goto();
});
test('should load the dashboard page', async ({ page }) => {
await dashboard.assertLoaded();
await expect(page).toHaveURL(/\/dashboard/);
});
test('should display main content area', async ({ page }) => {
// Dashboard should have a visible main content area
await expect(page.locator('main')).toBeVisible();
});
test('should have sidebar navigation', async ({ page }) => {
// Verify key navigation links are present
const nav = page.locator('nav').first();
if (await nav.isVisible({ timeout: 5_000 }).catch(() => false)) {
await expect(nav).toBeVisible();
// Check for common navigation items
const links = ['Accounts', 'Transactions', 'Budgets'];
for (const linkName of links) {
const link = nav.getByRole('link', { name: new RegExp(linkName, 'i') });
if (await link.isVisible({ timeout: 2_000 }).catch(() => false)) {
await expect(link).toBeVisible();
}
}
}
});
test('should navigate to accounts page', async ({ page }) => {
const accountsLink = page.getByRole('link', { name: /accounts/i }).first();
if (await accountsLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await accountsLink.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/accounts/);
}
});
});
test.describe('Dashboard with DB verification', () => {
test('should reflect database state', async ({ page, db }) => {
// Query the database to get expected account count
const result = await db.query(
`SELECT COUNT(*) as count FROM e2e_test_hoa.accounts`,
).catch(() => ({ rows: [{ count: '0' }] }));
const expectedCount = parseInt(result.rows[0].count, 10);
// Navigate to dashboard
const dashboard = new DashboardPage(page);
await dashboard.goto();
await dashboard.assertLoaded();
// If accounts exist, the dashboard should show some financial data
if (expectedCount > 0) {
// Dashboard should contain some numeric content indicating balances
await expect(page.locator('main')).not.toBeEmpty();
}
});
});

53
tests/e2e/visual.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Visual regression tests using Playwright's toHaveScreenshot().
*
* These capture pixel-level snapshots and compare against baselines.
* First run creates the baseline images in tests/e2e/visual.spec.ts-snapshots/.
*
* Update baselines: npx playwright test tests/e2e/visual.spec.ts --update-snapshots
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
// Use a clean context for login page screenshots
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Visual Regression', () => {
test('login page should match baseline', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.waitForReady();
// Wait for any animations to settle
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot('login-page.png', {
fullPage: true,
// Mask dynamic content that changes between runs
mask: [
// Mask any CSRF tokens or dynamic ids in the DOM
page.locator('input[type="hidden"]'),
],
});
});
});
test.describe('Authenticated Visual Regression', () => {
test('dashboard should match baseline', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Wait for charts/data to render
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true,
// Mask dates and dynamic numbers that change between runs
mask: [
page.locator('time'),
page.locator('[data-testid="current-date"]'),
],
});
});
});

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;

View File

@@ -0,0 +1,82 @@
/**
* Page object for the Accounts page (/accounts).
*
* Maps to: frontend/src/pages/accounts/AccountsPage.tsx
* Data: GET /api/accounts, POST /api/accounts, PUT /api/accounts/:id
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class AccountsPage extends BasePage {
readonly path = '/accounts';
// ─── Locators ────────────────────────────────────────────────
get heading() {
return this.page.getByRole('heading', { name: /accounts|chart of accounts/i }).first();
}
get addAccountButton() {
return this.page.getByRole('button', { name: /add|create|new/i }).first();
}
/** Account name input in the create/edit modal */
get nameInput() {
return this.page.getByLabel(/name/i).first();
}
/** Account number input */
get numberInput() {
return this.page.getByLabel(/number/i).first();
}
/** Account type select */
get typeSelect() {
return this.page.getByLabel(/type/i).first();
}
/** Save/submit button in modal */
get saveButton() {
return this.page.getByRole('button', { name: /save|create|submit/i }).first();
}
/** Cancel button in modal */
get cancelButton() {
return this.page.getByRole('button', { name: /cancel/i }).first();
}
// ─── Actions ─────────────────────────────────────────────────
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Wait for the accounts list or heading to be visible
await expect(this.page.locator('main')).toBeVisible();
}
/** Get the count of visible account rows */
async getAccountCount(): Promise<number> {
return this.tableRows.count();
}
/** Find an account row by name */
accountRow(name: string) {
return this.tableBody.locator('tr').filter({ hasText: name });
}
/** Assert an account exists in the table */
async assertAccountExists(name: string): Promise<void> {
await expect(this.accountRow(name)).toBeVisible();
}
/** Assert an account does NOT exist in the table */
async assertAccountNotExists(name: string): Promise<void> {
await expect(this.accountRow(name)).not.toBeVisible();
}
/** Open the create account modal */
async openCreateModal(): Promise<void> {
await this.addAccountButton.click();
await expect(this.nameInput).toBeVisible();
}
}

View File

@@ -0,0 +1,73 @@
/**
* Base page object with shared helpers.
*
* All page objects extend this class to inherit common navigation,
* waiting, and assertion patterns.
*/
import { type Page, type Locator, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** The path segment for this page (e.g. '/dashboard', '/accounts') */
abstract readonly path: string;
/** Navigate to this page */
async goto(): Promise<void> {
await this.page.goto(this.path);
await this.waitForReady();
}
/**
* Override in subclasses to wait for page-specific readiness signals.
* Default: waits for network idle.
*/
async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
/** Assert the page URL contains the expected path */
async assertOnPage(): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(this.path));
}
/** Get the page title text (Mantine AppShell header or h1) */
async getPageHeading(): Promise<string> {
const heading = this.page.getByRole('heading', { level: 1 }).first();
return heading.innerText();
}
/** Wait for a Mantine notification to appear with the given text */
async waitForNotification(text: string | RegExp): Promise<void> {
await expect(
this.page.locator('.mantine-Notification-root').filter({ hasText: text }),
).toBeVisible({ timeout: 10_000 });
}
/** Click a navigation link in the sidebar */
async navigateTo(linkText: string): Promise<void> {
await this.page.getByRole('link', { name: linkText }).click();
await this.page.waitForLoadState('networkidle');
}
/** Get a Mantine table body locator */
get tableBody(): Locator {
return this.page.locator('tbody');
}
/** Get all table rows */
get tableRows(): Locator {
return this.tableBody.locator('tr');
}
/** Wait for API response on a specific endpoint pattern */
async waitForApi(urlPattern: string | RegExp): Promise<void> {
await this.page.waitForResponse(
(response) =>
(typeof urlPattern === 'string'
? response.url().includes(urlPattern)
: urlPattern.test(response.url())) && response.status() < 400,
);
}
}

View File

@@ -0,0 +1,56 @@
/**
* Page object for the Dashboard (/dashboard).
*
* Maps to: frontend/src/pages/dashboard/DashboardPage.tsx
* Data: GET /api/reports/dashboard
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
// ─── Locators ────────────────────────────────────────────────
/** Main dashboard heading */
get heading() {
return this.page.getByRole('heading', { name: /dashboard/i }).first();
}
/** Account balance summary cards */
get balanceCards() {
return this.page.locator('[class*="Card"]').filter({ hasText: /balance|total/i });
}
/** Sidebar navigation */
get sidebar() {
return this.page.locator('nav').first();
}
/** Sidebar links */
sidebarLink(name: string) {
return this.sidebar.getByRole('link', { name });
}
// ─── Actions ─────────────────────────────────────────────────
/** Wait for dashboard data to load */
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Dashboard typically loads report data
await expect(this.page.locator('main')).toBeVisible();
}
/** Assert the dashboard has loaded with content */
async assertLoaded(): Promise<void> {
await this.assertOnPage();
await expect(this.page.locator('main')).not.toBeEmpty();
}
/** Navigate to a section via sidebar */
async navigateToSection(section: string): Promise<void> {
await this.sidebarLink(section).click();
await this.page.waitForLoadState('networkidle');
}
}

View File

@@ -0,0 +1,85 @@
/**
* Page object for the Login page (/login).
*
* Maps to: frontend/src/pages/auth/LoginPage.tsx
* Auth: POST /api/auth/login (Passport local strategy)
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly path = '/login';
// ─── Locators (resilient, role/label based) ──────────────────
get emailInput() {
return this.page.getByLabel('Email');
}
get passwordInput() {
return this.page.getByLabel('Password');
}
get signInButton() {
return this.page.getByRole('button', { name: 'Sign in' });
}
get passkeyButton() {
return this.page.getByRole('button', { name: /passkey/i });
}
get errorAlert() {
return this.page.getByRole('alert');
}
get registerLink() {
return this.page.getByRole('link', { name: /register/i });
}
// ─── MFA locators ────────────────────────────────────────────
get mfaHeading() {
return this.page.getByText('Two-Factor Authentication');
}
get mfaPinInput() {
return this.page.locator('.mantine-PinInput-input').first();
}
get mfaVerifyButton() {
return this.page.getByRole('button', { name: 'Verify' });
}
// ─── Actions ─────────────────────────────────────────────────
/** Fill and submit login credentials */
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
/** Login and wait for successful redirect */
async loginAndWaitForRedirect(
email: string,
password: string,
): Promise<void> {
await this.login(email, password);
await this.page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
}
/** Assert an error message is displayed */
async assertError(message: string | RegExp): Promise<void> {
await expect(this.errorAlert).toContainText(message);
}
/** Assert we're still on the login page */
async assertStillOnLogin(): Promise<void> {
await expect(this.page).toHaveURL(/\/login/);
}
override async waitForReady(): Promise<void> {
await expect(this.signInButton).toBeVisible();
}
}

View File

@@ -0,0 +1,10 @@
/**
* Re-export all page objects for convenient imports.
*
* Usage: import { LoginPage, DashboardPage } from '../page-objects';
*/
export { BasePage } from './BasePage';
export { LoginPage } from './LoginPage';
export { DashboardPage } from './DashboardPage';
export { AccountsPage } from './AccountsPage';