From dfd1bccb898beed88eac9b66bfaae39c0f61d832 Mon Sep 17 00:00:00 2001 From: JoeBot Date: Sun, 5 Apr 2026 12:40:05 -0400 Subject: [PATCH] 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 --- .env.test.example | 28 +++ .gitignore | 10 + TESTING_CONVENTIONS.md | 349 ++++++++++++++++++++++++++++ package.json | 24 ++ playwright.config.ts | 127 ++++++++++ tests/api/accounts.api.spec.ts | 140 +++++++++++ tests/api/auth.api.spec.ts | 124 ++++++++++ tests/auth.setup.ts | 50 ++++ tests/e2e/auth.spec.ts | 97 ++++++++ tests/e2e/dashboard.spec.ts | 76 ++++++ tests/e2e/visual.spec.ts | 53 +++++ tests/fixtures/auth.fixture.ts | 108 +++++++++ tests/fixtures/base.fixture.ts | 87 +++++++ tests/fixtures/db.fixture.ts | 124 ++++++++++ tests/fixtures/test-data.ts | 48 ++++ tests/page-objects/AccountsPage.ts | 82 +++++++ tests/page-objects/BasePage.ts | 73 ++++++ tests/page-objects/DashboardPage.ts | 56 +++++ tests/page-objects/LoginPage.ts | 85 +++++++ tests/page-objects/index.ts | 10 + 20 files changed, 1751 insertions(+) create mode 100644 .env.test.example create mode 100644 TESTING_CONVENTIONS.md create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/api/accounts.api.spec.ts create mode 100644 tests/api/auth.api.spec.ts create mode 100644 tests/auth.setup.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/dashboard.spec.ts create mode 100644 tests/e2e/visual.spec.ts create mode 100644 tests/fixtures/auth.fixture.ts create mode 100644 tests/fixtures/base.fixture.ts create mode 100644 tests/fixtures/db.fixture.ts create mode 100644 tests/fixtures/test-data.ts create mode 100644 tests/page-objects/AccountsPage.ts create mode 100644 tests/page-objects/BasePage.ts create mode 100644 tests/page-objects/DashboardPage.ts create mode 100644 tests/page-objects/LoginPage.ts create mode 100644 tests/page-objects/index.ts diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..801b116 --- /dev/null +++ b/.env.test.example @@ -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 diff --git a/.gitignore b/.gitignore index c71871d..d71f972 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,13 @@ coverage/ # TypeScript *.tsbuildinfo + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +tests/.auth/ +*-snapshots/ + +# Test environment +.env.test diff --git a/TESTING_CONVENTIONS.md b/TESTING_CONVENTIONS.md new file mode 100644 index 0000000..7b5adf6 --- /dev/null +++ b/TESTING_CONVENTIONS.md @@ -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/.spec.ts` | `auth.spec.ts` | +| API test files | `tests/api/.api.spec.ts` | `accounts.api.spec.ts` | +| Page objects | `tests/page-objects/.ts` | `LoginPage.ts` | +| Fixtures | `tests/fixtures/.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 ')`: + +```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 { + await this.page.waitForLoadState('networkidle'); + await expect(this.heading).toBeVisible(); + } + + async createItem(name: string): Promise { + 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/.spec.ts` for UI flows +- [ ] Create `tests/api/.api.spec.ts` for API endpoints +- [ ] Add sample data constants to `tests/fixtures/test-data.ts` if needed +- [ ] Run `npx playwright test tests/e2e/.spec.ts` to verify +- [ ] Update visual baselines if the feature changes existing pages diff --git a/package.json b/package.json new file mode 100644 index 0000000..349d19a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..96d4671 --- /dev/null +++ b/playwright.config.ts @@ -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', + }, + }), +}); diff --git a/tests/api/accounts.api.spec.ts b/tests/api/accounts.api.spec.ts new file mode 100644 index 0000000..1ef403e --- /dev/null +++ b/tests/api/accounts.api.spec.ts @@ -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(); + }); +}); diff --git a/tests/api/auth.api.spec.ts b/tests/api/auth.api.spec.ts new file mode 100644 index 0000000..621c9f9 --- /dev/null +++ b/tests/api/auth.api.spec.ts @@ -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()); + }); +}); diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts new file mode 100644 index 0000000..c5fdf71 --- /dev/null +++ b/tests/auth.setup.ts @@ -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 }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..6c81e20 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -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/); + } + }); +}); diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts new file mode 100644 index 0000000..d3bf854 --- /dev/null +++ b/tests/e2e/dashboard.spec.ts @@ -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(); + } + }); +}); diff --git a/tests/e2e/visual.spec.ts b/tests/e2e/visual.spec.ts new file mode 100644 index 0000000..d220a07 --- /dev/null +++ b/tests/e2e/visual.spec.ts @@ -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"]'), + ], + }); + }); +}); diff --git a/tests/fixtures/auth.fixture.ts b/tests/fixtures/auth.fixture.ts new file mode 100644 index 0000000..2aa1b6c --- /dev/null +++ b/tests/fixtures/auth.fixture.ts @@ -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; + organizations: Array>; +} + +/** + * 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 { + 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 { + 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 { + 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); +} diff --git a/tests/fixtures/base.fixture.ts b/tests/fixtures/base.fixture.ts new file mode 100644 index 0000000..0aabd5e --- /dev/null +++ b/tests/fixtures/base.fixture.ts @@ -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({ + /** + * 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'; diff --git a/tests/fixtures/db.fixture.ts b/tests/fixtures/db.fixture.ts new file mode 100644 index 0000000..44164d6 --- /dev/null +++ b/tests/fixtures/db.fixture.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + if (this.pool) { + await this.pool.end(); + this.pool = null; + } + } +} + +export const testDb = new TestDatabase(); diff --git a/tests/fixtures/test-data.ts b/tests/fixtures/test-data.ts new file mode 100644 index 0000000..4cab53b --- /dev/null +++ b/tests/fixtures/test-data.ts @@ -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; diff --git a/tests/page-objects/AccountsPage.ts b/tests/page-objects/AccountsPage.ts new file mode 100644 index 0000000..c2938e4 --- /dev/null +++ b/tests/page-objects/AccountsPage.ts @@ -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 { + 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 { + 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 { + await expect(this.accountRow(name)).toBeVisible(); + } + + /** Assert an account does NOT exist in the table */ + async assertAccountNotExists(name: string): Promise { + await expect(this.accountRow(name)).not.toBeVisible(); + } + + /** Open the create account modal */ + async openCreateModal(): Promise { + await this.addAccountButton.click(); + await expect(this.nameInput).toBeVisible(); + } +} diff --git a/tests/page-objects/BasePage.ts b/tests/page-objects/BasePage.ts new file mode 100644 index 0000000..eead802 --- /dev/null +++ b/tests/page-objects/BasePage.ts @@ -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 { + 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 { + await this.page.waitForLoadState('networkidle'); + } + + /** Assert the page URL contains the expected path */ + async assertOnPage(): Promise { + await expect(this.page).toHaveURL(new RegExp(this.path)); + } + + /** Get the page title text (Mantine AppShell header or h1) */ + async getPageHeading(): Promise { + 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 { + 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 { + 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 { + await this.page.waitForResponse( + (response) => + (typeof urlPattern === 'string' + ? response.url().includes(urlPattern) + : urlPattern.test(response.url())) && response.status() < 400, + ); + } +} diff --git a/tests/page-objects/DashboardPage.ts b/tests/page-objects/DashboardPage.ts new file mode 100644 index 0000000..0b623a7 --- /dev/null +++ b/tests/page-objects/DashboardPage.ts @@ -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 { + 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 { + await this.assertOnPage(); + await expect(this.page.locator('main')).not.toBeEmpty(); + } + + /** Navigate to a section via sidebar */ + async navigateToSection(section: string): Promise { + await this.sidebarLink(section).click(); + await this.page.waitForLoadState('networkidle'); + } +} diff --git a/tests/page-objects/LoginPage.ts b/tests/page-objects/LoginPage.ts new file mode 100644 index 0000000..9b60965 --- /dev/null +++ b/tests/page-objects/LoginPage.ts @@ -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 { + 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 { + 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 { + await expect(this.errorAlert).toContainText(message); + } + + /** Assert we're still on the login page */ + async assertStillOnLogin(): Promise { + await expect(this.page).toHaveURL(/\/login/); + } + + override async waitForReady(): Promise { + await expect(this.signInButton).toBeVisible(); + } +} diff --git a/tests/page-objects/index.ts b/tests/page-objects/index.ts new file mode 100644 index 0000000..e49c579 --- /dev/null +++ b/tests/page-objects/index.ts @@ -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';