Compare commits
8 Commits
5ae6f672be
...
claude/com
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd1bccb89 | ||
| 629d112850 | |||
| 32506d6a2e | |||
| 9a60970837 | |||
| 1ade446187 | |||
|
|
d430b96b51 | ||
|
|
140cd7acb7 | ||
| 06bc0181f8 |
28
.env.test.example
Normal file
28
.env.test.example
Normal 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
10
.gitignore
vendored
@@ -44,3 +44,13 @@ coverage/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
tests/.auth/
|
||||
*-snapshots/
|
||||
|
||||
# Test environment
|
||||
.env.test
|
||||
|
||||
229
CLAUDE.md
Normal file
229
CLAUDE.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# CLAUDE.md – HOA Financial Platform (HOALedgerIQ)
|
||||
|
||||
## Project Overview
|
||||
|
||||
Multi-tenant SaaS platform for HOA (Homeowners Association) financial management. Handles chart of accounts, journal entries, budgets, invoices, payments, reserve planning, and board scenario planning.
|
||||
|
||||
---
|
||||
|
||||
## Stack & Framework
|
||||
|
||||
| Layer | Technology |
|
||||
| --------- | --------------------------------------------------- |
|
||||
| Backend | **NestJS 10** (TypeScript), runs on port 3000 |
|
||||
| Frontend | **React 18** + Vite 5 + Mantine UI + Zustand |
|
||||
| Database | **PostgreSQL** via **TypeORM 0.3** |
|
||||
| Cache | **Redis** (BullMQ for queues) |
|
||||
| Auth | **Passport.js** – JWT access + httpOnly refresh |
|
||||
| Payments | **Stripe** (checkout, subscriptions, webhooks) |
|
||||
| Email | **Resend** |
|
||||
| AI | NVIDIA API (Qwen model) for investment advisor |
|
||||
| Monitoring| **New Relic** APM (app name: `HOALedgerIQ_App`) |
|
||||
| Infra | Docker Compose (dev + prod), Nginx reverse proxy |
|
||||
|
||||
---
|
||||
|
||||
## Auth Pattern
|
||||
|
||||
- **Access token**: JWT, 1-hour TTL, payload `{ sub, email, orgId, role, isSuperadmin }`
|
||||
- **Refresh token**: 64-byte random, SHA256-hashed in DB, 30-day TTL, sent as httpOnly cookie `ledgeriq_rt`
|
||||
- **MFA**: TOTP via `otplib`, challenge token (5-min TTL), recovery codes
|
||||
- **Passkeys**: WebAuthn via `@simplewebauthn/server`
|
||||
- **SSO**: Google OAuth 2.0, Azure AD
|
||||
- **Password hashing**: bcryptjs, cost 12
|
||||
- **Rate limiting**: 100 req/min global (Throttler), custom per endpoint
|
||||
|
||||
### Guards & Middleware
|
||||
|
||||
- `TenantMiddleware` – extracts `orgId` from JWT, sets tenant schema (60s cache)
|
||||
- `JwtAuthGuard` – Passport JWT guard on all protected routes
|
||||
- `WriteAccessGuard` – blocks write ops for `viewer` role and `past_due` orgs
|
||||
- `@AllowViewer()` decorator – exempts read endpoints from WriteAccessGuard
|
||||
|
||||
### Roles
|
||||
|
||||
`president`, `treasurer`, `secretary`, `member_at_large`, `manager`, `homeowner`, `admin`, `viewer`
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenant Architecture
|
||||
|
||||
- **Shared schema** (`shared`): users, organizations, user_organizations, refresh_tokens, invite_tokens, login_history, cd_rates
|
||||
- **Tenant schemas** (dynamic, per org): accounts, journal_entries, budgets, invoices, payments, units, vendors, etc.
|
||||
- Schema name stored in `shared.organizations.schema_name`
|
||||
|
||||
---
|
||||
|
||||
## Route Map (180+ endpoints)
|
||||
|
||||
### Auth (`/api/auth`)
|
||||
| Method | Path | Purpose |
|
||||
| ------ | ----------------------- | -------------------------------- |
|
||||
| POST | /login | Email/password login |
|
||||
| POST | /refresh | Refresh access token (cookie) |
|
||||
| POST | /logout | Revoke refresh token |
|
||||
| POST | /logout-everywhere | Revoke all sessions |
|
||||
| GET | /profile | Current user profile |
|
||||
| POST | /register | Register (disabled by default) |
|
||||
| POST | /activate | Activate invited user |
|
||||
| POST | /forgot-password | Request password reset |
|
||||
| POST | /reset-password | Reset with token |
|
||||
| PATCH | /change-password | Change password (authed) |
|
||||
| POST | /switch-org | Switch active organization |
|
||||
|
||||
### Auth MFA (`/api/auth/mfa`)
|
||||
| POST | /setup | POST /enable | POST /verify | POST /disable | GET /status |
|
||||
|
||||
### Auth Passkeys (`/api/auth/passkeys`)
|
||||
| POST /register-options | POST /register | POST /login-options | POST /login | GET / | DELETE /:id |
|
||||
|
||||
### Admin (`/api/admin`) – superadmin only
|
||||
| GET /metrics | GET /users | GET /organizations | PUT /organizations/:id/subscription | POST /impersonate/:userId | POST /tenants |
|
||||
|
||||
### Organizations (`/api/organizations`)
|
||||
| POST / | GET / | PATCH /settings | GET /members | POST /members | PUT /members/:id/role | DELETE /members/:id |
|
||||
|
||||
### Accounts (`/api/accounts`)
|
||||
| GET / | GET /trial-balance | POST / | PUT /:id | PUT /:id/set-primary | POST /bulk-opening-balances | POST /:id/opening-balance | POST /:id/adjust-balance |
|
||||
|
||||
### Journal Entries (`/api/journal-entries`)
|
||||
| GET / | GET /:id | POST / | POST /:id/post | POST /:id/void |
|
||||
|
||||
### Budgets (`/api/budgets`)
|
||||
| GET /:year | PUT /:year | GET /:year/vs-actual | POST /:year/import | GET /:year/template |
|
||||
|
||||
### Invoices (`/api/invoices`)
|
||||
| GET / | GET /:id | POST /generate-preview | POST /generate-bulk | POST /apply-late-fees |
|
||||
|
||||
### Payments (`/api/payments`)
|
||||
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id |
|
||||
|
||||
### Units (`/api/units`)
|
||||
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id | GET /export | POST /import |
|
||||
|
||||
### Vendors (`/api/vendors`)
|
||||
| GET / | GET /:id | POST / | PUT /:id | GET /export | POST /import | GET /1099-data |
|
||||
|
||||
### Reports (`/api/reports`)
|
||||
| GET /dashboard | GET /balance-sheet | GET /income-statement | GET /cash-flow | GET /cash-flow-sankey | GET /aging | GET /year-end | GET /cash-flow-forecast | GET /quarterly |
|
||||
|
||||
### Board Planning (`/api/board-planning`)
|
||||
Scenarios CRUD, scenario investments, scenario assessments, projections, budget plans – 28 endpoints total.
|
||||
|
||||
### Other Modules
|
||||
- `/api/fiscal-periods` – list, close, lock
|
||||
- `/api/reserve-components` – CRUD
|
||||
- `/api/capital-projects` – CRUD
|
||||
- `/api/projects` – CRUD + planning + import/export
|
||||
- `/api/assessment-groups` – CRUD + summary + default
|
||||
- `/api/monthly-actuals` – GET/POST /:year/:month
|
||||
- `/api/health-scores` – latest + calculate
|
||||
- `/api/investment-planning` – snapshot, market-rates, recommendations
|
||||
- `/api/investment-accounts` – CRUD
|
||||
- `/api/attachments` – upload, list, download, delete (10MB limit)
|
||||
- `/api/onboarding` – progress get/patch
|
||||
- `/api/billing` – trial, checkout, webhook, subscription, portal
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
- **Connection pool**: min 5, max 30, 30s idle, 5s connect timeout
|
||||
- **Migrations**: SQL files in `db/migrations/` (manual execution, no ORM runner)
|
||||
- **Init script**: `db/init/00-init.sql` (shared schema DDL)
|
||||
|
||||
---
|
||||
|
||||
## Key File Paths
|
||||
|
||||
| Purpose | Path |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| NestJS bootstrap | `backend/src/main.ts` |
|
||||
| Root module | `backend/src/app.module.ts` |
|
||||
| Auth controller | `backend/src/modules/auth/auth.controller.ts` |
|
||||
| Auth service | `backend/src/modules/auth/auth.service.ts` |
|
||||
| Refresh token svc | `backend/src/modules/auth/refresh-token.service.ts` |
|
||||
| JWT strategy | `backend/src/modules/auth/strategies/jwt.strategy.ts` |
|
||||
| Tenant middleware | `backend/src/database/tenant.middleware.ts` |
|
||||
| Write-access guard | `backend/src/common/guards/write-access.guard.ts` |
|
||||
| DB schema init | `db/init/00-init.sql` |
|
||||
| Env example | `.env.example` |
|
||||
| Docker compose (dev) | `docker-compose.yml` |
|
||||
| Frontend entry | `frontend/src/main.tsx` |
|
||||
| Frontend pages | `frontend/src/pages/` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (critical)
|
||||
|
||||
```
|
||||
DATABASE_URL – PostgreSQL connection string
|
||||
REDIS_URL – Redis connection
|
||||
JWT_SECRET – JWT signing key
|
||||
INVITE_TOKEN_SECRET – Invite token signing
|
||||
STRIPE_SECRET_KEY – Stripe API key
|
||||
STRIPE_WEBHOOK_SECRET – Stripe webhook verification
|
||||
RESEND_API_KEY – Email service
|
||||
NEW_RELIC_APP_NAME – "HOALedgerIQ_App"
|
||||
NEW_RELIC_LICENSE_KEY – New Relic license
|
||||
APP_URL – Base URL for email links
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Relic
|
||||
|
||||
- **App name**: `HOALedgerIQ_App` (env: `NEW_RELIC_APP_NAME`)
|
||||
- Enabled via `NEW_RELIC_ENABLED=true`
|
||||
- NRQL query library: `load-tests/analysis/nrql-queries.sql`
|
||||
|
||||
---
|
||||
|
||||
## Load Testing
|
||||
|
||||
### Run k6 scenarios
|
||||
|
||||
```bash
|
||||
# Auth + Dashboard flow (staging)
|
||||
k6 run --env TARGET_ENV=staging load-tests/scenarios/auth-dashboard-flow.js
|
||||
|
||||
# CRUD flow (staging)
|
||||
k6 run --env TARGET_ENV=staging load-tests/scenarios/crud-flow.js
|
||||
|
||||
# Local dev
|
||||
k6 run --env TARGET_ENV=local load-tests/scenarios/auth-dashboard-flow.js
|
||||
```
|
||||
|
||||
### Conventions
|
||||
|
||||
- Scenarios live in `load-tests/scenarios/`
|
||||
- Config in `load-tests/config/environments.json` (staging/production/local thresholds)
|
||||
- Test users parameterized from `load-tests/config/user-pool.csv`
|
||||
- Baseline results stored in `load-tests/analysis/baseline.json`
|
||||
- NRQL queries for New Relic in `load-tests/analysis/nrql-queries.sql`
|
||||
- All k6 scripts use `SharedArray` for user pool, `http.batch()` for parallel requests
|
||||
- Custom metrics: `*_duration` trends + `*_error_rate` rates per journey
|
||||
- Thresholds: p95 latency + error rate per environment
|
||||
|
||||
### User Pool CSV Format
|
||||
|
||||
```
|
||||
email,password,orgId,role
|
||||
```
|
||||
|
||||
Roles match the app: `treasurer`, `admin`, `president`, `manager`, `member_at_large`, `viewer`, `homeowner`
|
||||
|
||||
---
|
||||
|
||||
## Fix Conventions
|
||||
|
||||
- Backend tests: `npm run test` (Jest, `*.spec.ts` co-located with source)
|
||||
- E2E tests: `npm run test:e2e`
|
||||
- Backend build: `npm run build` (NestJS CLI)
|
||||
- Frontend dev: `npm run dev` (Vite, port 5173)
|
||||
- Frontend build: `npm run build`
|
||||
- Always run `npm run build` in `backend/` after changes to verify compilation
|
||||
- TypeORM entities use decorators (`@Entity`, `@Column`, etc.)
|
||||
- Multi-tenant: any new module touching tenant data must use `TenantService` to get the correct schema connection
|
||||
- New endpoints need `@UseGuards(JwtAuthGuard)` and should respect `WriteAccessGuard`
|
||||
- Use `@AllowViewer()` on read-only endpoints
|
||||
349
TESTING_CONVENTIONS.md
Normal file
349
TESTING_CONVENTIONS.md
Normal 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
|
||||
@@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
||||
import { BillingModule } from './modules/billing/billing.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||
import { IdeasModule } from './modules/ideas/ideas.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
@@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
BillingModule,
|
||||
EmailModule,
|
||||
OnboardingModule,
|
||||
IdeasModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { OrganizationsService } from '../organizations/organizations.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import { IdeasService } from '../ideas/ideas.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@ApiTags('admin')
|
||||
@@ -17,6 +18,7 @@ export class AdminController {
|
||||
private usersService: UsersService,
|
||||
private orgService: OrganizationsService,
|
||||
private analyticsService: AdminAnalyticsService,
|
||||
private ideasService: IdeasService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
@@ -196,4 +198,45 @@ export class AdminController {
|
||||
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
|
||||
// ── Ideation ──
|
||||
|
||||
@Get('ideas')
|
||||
async listAllIdeas(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.ideasService.findAll();
|
||||
}
|
||||
|
||||
@Put('ideas/:id/status')
|
||||
async updateIdeaStatus(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const idea = await this.ideasService.updateStatus(id, body.status);
|
||||
return { success: true, idea };
|
||||
}
|
||||
|
||||
@Put('ideas/:id/note')
|
||||
async updateIdeaNote(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { adminNote: string },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const idea = await this.ideasService.updateNote(id, body.adminNote);
|
||||
return { success: true, idea };
|
||||
}
|
||||
|
||||
@Put('organizations/:id/settings')
|
||||
async updateOrgSettings(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: Record<string, any>,
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
const org = await this.orgService.updateSettings(id, body);
|
||||
return { success: true, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
import { IdeasModule } from '../ideas/ideas.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OrganizationsModule,
|
||||
IdeasModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateIdeaDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Organization } from '../../organizations/entities/organization.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'ideas' })
|
||||
export class Idea {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'org_id' })
|
||||
orgId: string;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ length: 20, default: 'new' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'admin_note', type: 'text', nullable: true })
|
||||
adminNote: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => Organization)
|
||||
@JoinColumn({ name: 'org_id' })
|
||||
organization: Organization;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
}
|
||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { IdeasService } from './ideas.service';
|
||||
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||
|
||||
@ApiTags('ideas')
|
||||
@Controller('ideas')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IdeasController {
|
||||
constructor(private ideasService: IdeasService) {}
|
||||
|
||||
@Post()
|
||||
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
|
||||
const orgId = req.user.orgId;
|
||||
const userId = req.user.userId || req.user.sub;
|
||||
const idea = await this.ideasService.create(orgId, userId, dto);
|
||||
return { success: true, idea };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findByOrg(@Req() req: any) {
|
||||
const orgId = req.user.orgId;
|
||||
return this.ideasService.findByOrg(orgId);
|
||||
}
|
||||
}
|
||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Idea } from './entities/idea.entity';
|
||||
import { Organization } from '../organizations/entities/organization.entity';
|
||||
import { IdeasController } from './ideas.controller';
|
||||
import { IdeasService } from './ideas.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Idea, Organization])],
|
||||
controllers: [IdeasController],
|
||||
providers: [IdeasService],
|
||||
exports: [IdeasService],
|
||||
})
|
||||
export class IdeasModule {}
|
||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Idea } from './entities/idea.entity';
|
||||
import { Organization } from '../organizations/entities/organization.entity';
|
||||
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||
|
||||
@Injectable()
|
||||
export class IdeasService {
|
||||
constructor(
|
||||
@InjectRepository(Idea)
|
||||
private ideasRepository: Repository<Idea>,
|
||||
@InjectRepository(Organization)
|
||||
private orgRepository: Repository<Organization>,
|
||||
) {}
|
||||
|
||||
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
||||
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
if (org.settings?.ideationEnabled !== true) {
|
||||
throw new ForbiddenException('Ideation is not enabled for this organization');
|
||||
}
|
||||
|
||||
const idea = this.ideasRepository.create({
|
||||
orgId,
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
});
|
||||
return this.ideasRepository.save(idea);
|
||||
}
|
||||
|
||||
async findByOrg(orgId: string): Promise<Idea[]> {
|
||||
return this.ideasRepository.find({
|
||||
where: { orgId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(): Promise<any[]> {
|
||||
return this.ideasRepository
|
||||
.createQueryBuilder('idea')
|
||||
.leftJoin('idea.organization', 'org')
|
||||
.leftJoin('idea.user', 'user')
|
||||
.select([
|
||||
'idea.id AS id',
|
||||
'idea.title AS title',
|
||||
'idea.description AS description',
|
||||
'idea.status AS status',
|
||||
'idea.createdAt AS "createdAt"',
|
||||
'idea.adminNote AS "adminNote"',
|
||||
'org.id AS "orgId"',
|
||||
'org.name AS "orgName"',
|
||||
'user.id AS "userId"',
|
||||
'user.email AS "userEmail"',
|
||||
'user.firstName AS "userFirstName"',
|
||||
'user.lastName AS "userLastName"',
|
||||
])
|
||||
.orderBy('idea.createdAt', 'DESC')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: string): Promise<Idea> {
|
||||
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||
if (!idea) {
|
||||
throw new NotFoundException('Idea not found');
|
||||
}
|
||||
|
||||
idea.status = status;
|
||||
return this.ideasRepository.save(idea);
|
||||
}
|
||||
|
||||
async updateNote(id: string, adminNote: string): Promise<Idea> {
|
||||
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||
if (!idea) {
|
||||
throw new NotFoundException('Idea not found');
|
||||
}
|
||||
|
||||
idea.adminNote = adminNote;
|
||||
return this.ideasRepository.save(idea);
|
||||
}
|
||||
}
|
||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Ideation feature: shared ideas table for cross-tenant idea submissions
|
||||
CREATE TABLE IF NOT EXISTS shared.ideas (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);
|
||||
2
db/migrations/019-ideas-admin-note.sql
Normal file
2
db/migrations/019-ideas-admin-note.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add private admin note column to ideas table
|
||||
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;
|
||||
@@ -29,6 +29,7 @@ import { SettingsPage } from './pages/settings/SettingsPage';
|
||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||
import { AdminPage } from './pages/admin/AdminPage';
|
||||
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
|
||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||
@@ -133,6 +134,7 @@ export function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="ideas" element={<AdminIdeasPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Main app routes (require auth + org) */}
|
||||
|
||||
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface IdeaModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const submitIdea = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/ideas', { title, description });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({
|
||||
message: err.response?.data?.message || 'Failed to submit idea',
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Title"
|
||||
placeholder="Brief summary of your idea"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Describe your idea in more detail (optional)"
|
||||
minRows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => submitIdea.mutate()}
|
||||
loading={submitIdea.isPending}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
Submit Idea
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconEyeOff,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconBulb,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { AppTour } from '../onboarding/AppTour';
|
||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||
import { IdeaModal } from '../ideas/IdeaModal';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -28,6 +30,10 @@ export function AppLayout() {
|
||||
const location = useLocation();
|
||||
const isImpersonating = !!impersonationOriginal;
|
||||
|
||||
// ── Ideation State ──
|
||||
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
||||
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
||||
|
||||
// ── Onboarding State ──
|
||||
const [showTour, setShowTour] = useState(false);
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
@@ -121,6 +127,13 @@ export function AppLayout() {
|
||||
{currentOrg && (
|
||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||
)}
|
||||
{ideationEnabled && (
|
||||
<Tooltip label="Submit an idea">
|
||||
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
|
||||
<IconBulb size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -209,6 +222,9 @@ export function AppLayout() {
|
||||
{/* ── Onboarding Components ── */}
|
||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||
|
||||
{/* ── Ideation Modal ── */}
|
||||
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconCalculator,
|
||||
IconGitCompare,
|
||||
IconScale,
|
||||
IconBulb,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
@@ -132,6 +133,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
<NavLink
|
||||
label="Idea Submissions"
|
||||
leftSection={<IconBulb size={18} />}
|
||||
active={location.pathname === '/admin/ideas'}
|
||||
onClick={() => go('/admin/ideas')}
|
||||
color="yellow"
|
||||
/>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
@@ -230,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
onClick={() => go('/admin')}
|
||||
color="red"
|
||||
/>
|
||||
<NavLink
|
||||
label="Idea Submissions"
|
||||
leftSection={<IconBulb size={18} />}
|
||||
active={location.pathname === '/admin/ideas'}
|
||||
onClick={() => go('/admin/ideas')}
|
||||
color="yellow"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
||||
Tooltip, Paper,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBulb, IconSearch, IconNote, IconFilter,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface AdminIdea {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
adminNote: string | null;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userFirstName: string;
|
||||
userLastName: string;
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
new: 'blue',
|
||||
reviewed: 'yellow',
|
||||
accepted: 'green',
|
||||
rejected: 'red',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'reviewed', label: 'Reviewed' },
|
||||
{ value: 'accepted', label: 'Accepted' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
export function AdminIdeasPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
||||
queryKey: ['admin-ideas'],
|
||||
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
||||
await api.put(`/admin/ideas/${id}/status`, { status });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||
notifications.show({ message: 'Status updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateNote = useMutation({
|
||||
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
||||
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||
notifications.show({ message: 'Note saved', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const openIdeaDetail = (idea: AdminIdea) => {
|
||||
setSelectedIdea(idea);
|
||||
setNoteText(idea.adminNote || '');
|
||||
openDetail();
|
||||
};
|
||||
|
||||
const handleSaveNote = () => {
|
||||
if (selectedIdea) {
|
||||
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = (ideas || []).filter((idea) => {
|
||||
const matchesSearch = !search ||
|
||||
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const counts = {
|
||||
total: ideas?.length || 0,
|
||||
new: ideas?.filter(i => i.status === 'new').length || 0,
|
||||
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
||||
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
||||
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Center h={400}><Loader /></Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<IconBulb size={28} />
|
||||
<Title order={2}>Idea Submissions</Title>
|
||||
</Group>
|
||||
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Summary cards */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
||||
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
||||
</Paper>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
||||
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
||||
</Paper>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
||||
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
||||
</Paper>
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
||||
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search ideas, tenants, users..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
leftSection={<IconFilter size={16} />}
|
||||
data={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
w={160}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Ideas table */}
|
||||
<Card withBorder p={0}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Tenant</Table.Th>
|
||||
<Table.Th>Submitted By</Table.Th>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={40}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
filtered.map((idea) => (
|
||||
<Table.Tr
|
||||
key={idea.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openIdeaDetail(idea)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
||||
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
||||
{idea.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{idea.adminNote && (
|
||||
<Tooltip label="Has admin note">
|
||||
<IconNote size={16} color="gray" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
onClose={closeDetail}
|
||||
title={<Text fw={600}>Idea Detail</Text>}
|
||||
size="lg"
|
||||
>
|
||||
{selectedIdea && (
|
||||
<Stack>
|
||||
<Card withBorder>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Text size="xs" c="dimmed">Tenant</Text>
|
||||
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
||||
<Text size="xs" c="dimmed">Submitted By</Text>
|
||||
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
||||
<Text size="xs" c="dimmed">Date</Text>
|
||||
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Title</Text>
|
||||
<Text size="sm">{selectedIdea.title}</Text>
|
||||
{selectedIdea.description && (
|
||||
<>
|
||||
<Text fw={600} mt="md" mb="xs">Description</Text>
|
||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Status</Text>
|
||||
<Select
|
||||
data={statusOptions}
|
||||
value={selectedIdea.status}
|
||||
onChange={(val) => {
|
||||
if (val && val !== selectedIdea.status) {
|
||||
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
||||
onSuccess: () => {
|
||||
setSelectedIdea({ ...selectedIdea, status: val });
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600}>Private Admin Note</Text>
|
||||
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
||||
minRows={3}
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="xs"
|
||||
onClick={handleSaveNote}
|
||||
loading={updateNote.isPending}
|
||||
disabled={noteText === (selectedIdea.adminNote || '')}
|
||||
>
|
||||
Save Note
|
||||
</Button>
|
||||
</Card>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -211,6 +211,16 @@ export function AdminPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleIdeation = useMutation({
|
||||
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
||||
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const impersonateUser = useMutation({
|
||||
mutationFn: async (userId: string) => {
|
||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||
@@ -782,6 +792,27 @@ export function AdminPage() {
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Feature Toggles</Text>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<IconBulb size={16} />
|
||||
<div>
|
||||
<Text size="sm">Ideation</Text>
|
||||
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch
|
||||
checked={tenantDetail.organization.settings?.ideationEnabled === true}
|
||||
onChange={(e) => {
|
||||
if (selectedOrgId) {
|
||||
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Text fw={600} mb="xs">Subscription</Text>
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -237,7 +237,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.03.18</Badge>
|
||||
<Badge variant="light">2026.4.2</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
|
||||
141
load-tests/analysis/baseline.json
Normal file
141
load-tests/analysis/baseline.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"_meta": {
|
||||
"capturedAt": null,
|
||||
"environment": "staging",
|
||||
"k6Version": null,
|
||||
"vus": null,
|
||||
"duration": null,
|
||||
"notes": "Empty baseline – populate after first stable load test run"
|
||||
},
|
||||
"auth": {
|
||||
"POST /api/auth/login": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"POST /api/auth/refresh": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"POST /api/auth/logout": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/auth/profile": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"GET /api/reports/dashboard": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/balance-sheet": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/income-statement": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/aging": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/health-scores/latest": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/cash-flow": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/cash-flow-forecast": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
}
|
||||
},
|
||||
"crud": {
|
||||
"units": {
|
||||
"GET /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"DELETE /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"vendors": {
|
||||
"GET /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"journalEntries": {
|
||||
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/journal-entries/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries/:id/void": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"payments": {
|
||||
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"DELETE /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"accounts": {
|
||||
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/accounts/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"invoices": {
|
||||
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/invoices/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
}
|
||||
},
|
||||
"boardPlanning": {
|
||||
"GET /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/board-planning/scenarios/:id/projection": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/board-planning/budget-plans": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"organizations": {
|
||||
"GET /api/organizations": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/organizations/members": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
}
|
||||
}
|
||||
270
load-tests/analysis/nrql-queries.sql
Normal file
270
load-tests/analysis/nrql-queries.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- =============================================================================
|
||||
-- HOA Financial Platform (HOALedgerIQ) – New Relic NRQL Query Library
|
||||
-- App Name: HOALedgerIQ_App (see NEW_RELIC_APP_NAME env var)
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. OVERVIEW & THROUGHPUT
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Overall throughput (requests/min) over past hour
|
||||
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Throughput by HTTP method
|
||||
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Apdex score over time
|
||||
SELECT apdex(duration, t: 0.5)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. AUTHENTICATION ENDPOINTS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Login latency (p50, p95, p99)
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Login Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/login'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Login error rate
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Login Error %'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/login'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Token refresh latency
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Refresh Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/refresh'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Auth endpoints overview
|
||||
SELECT count(*), average(duration), percentile(duration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/auth/%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. DASHBOARD & REPORTS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Dashboard KPI latency
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Dashboard Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/reports/dashboard'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- All report endpoints performance
|
||||
SELECT count(*), average(duration), percentile(duration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/reports/%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Slowest report queries (> 2s)
|
||||
SELECT count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/reports/%'
|
||||
AND duration > 2
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. CRUD OPERATIONS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Units endpoint latency by method
|
||||
SELECT percentile(duration, 50, 95) AS 'Units Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/units%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Vendors endpoint latency by method
|
||||
SELECT percentile(duration, 50, 95) AS 'Vendors Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/vendors%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Journal entries performance
|
||||
SELECT percentile(duration, 50, 95) AS 'JE Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/journal-entries%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Payments performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Payments Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/payments%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Accounts performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Accounts Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/accounts%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Invoices performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Invoices Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/invoices%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. MULTI-TENANT / ORG OPERATIONS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Organizations endpoint performance
|
||||
SELECT percentile(duration, 50, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/organizations%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Board planning (complex module) latency
|
||||
SELECT count(*), percentile(duration, 50, 95, 99)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/board-planning%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. ERROR ANALYSIS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Error rate by endpoint (top 20 offenders)
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Error %',
|
||||
count(*) AS 'Total'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 20;
|
||||
|
||||
-- 5xx errors specifically
|
||||
SELECT count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND httpResponseCode >= 500
|
||||
FACET request.uri, httpResponseCode
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Error rate over time
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 500) AS 'Server Error %',
|
||||
percentage(count(*), WHERE httpResponseCode >= 400 AND httpResponseCode < 500) AS 'Client Error %'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. DATABASE & EXTERNAL SERVICES
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Database call duration (TypeORM / Postgres)
|
||||
SELECT average(databaseDuration), percentile(databaseDuration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Slowest DB transactions
|
||||
SELECT average(databaseDuration), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND databaseDuration > 1
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 20;
|
||||
|
||||
-- External service calls (Stripe, Resend, NVIDIA AI)
|
||||
SELECT average(externalDuration), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND externalDuration > 0
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. LOAD TEST COMPARISON
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Compare metrics between two time windows (baseline vs test)
|
||||
-- Adjust SINCE/UNTIL for your test windows
|
||||
SELECT percentile(duration, 50, 95, 99), count(*), percentage(count(*), WHERE httpResponseCode >= 500)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 2 hours ago
|
||||
UNTIL 1 hour ago
|
||||
COMPARE WITH 1 hour ago;
|
||||
|
||||
-- Per-endpoint comparison during load test window
|
||||
SELECT average(duration), percentile(duration, 95), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 50;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. INFRASTRUCTURE (if NR Infrastructure agent is installed)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- CPU utilization
|
||||
SELECT average(cpuPercent)
|
||||
FROM SystemSample
|
||||
WHERE hostname LIKE '%hoaledgeriq%'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Memory utilization
|
||||
SELECT average(memoryUsedPercent)
|
||||
FROM SystemSample
|
||||
WHERE hostname LIKE '%hoaledgeriq%'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Connection pool saturation (custom metric – requires NR custom events)
|
||||
-- SELECT average(custom.db.pool.active), average(custom.db.pool.idle)
|
||||
-- FROM Metric
|
||||
-- WHERE appName = 'HOALedgerIQ_App'
|
||||
-- SINCE 1 hour ago
|
||||
-- TIMESERIES AUTO;
|
||||
53
load-tests/config/environments.json
Normal file
53
load-tests/config/environments.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"staging": {
|
||||
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||
"stages": {
|
||||
"rampUp": 10,
|
||||
"steady": 25,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 2000,
|
||||
"login_p95": 1500,
|
||||
"dashboard_p95": 3000,
|
||||
"crud_write_p95": 2000,
|
||||
"crud_read_p95": 1500,
|
||||
"error_rate": 0.05
|
||||
},
|
||||
"notes": "Staging environment – smaller infra, relaxed thresholds"
|
||||
},
|
||||
"production": {
|
||||
"baseUrl": "https://app.hoaledgeriq.com",
|
||||
"stages": {
|
||||
"rampUp": 20,
|
||||
"steady": 50,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 1000,
|
||||
"login_p95": 800,
|
||||
"dashboard_p95": 1500,
|
||||
"crud_write_p95": 1000,
|
||||
"crud_read_p95": 800,
|
||||
"error_rate": 0.01
|
||||
},
|
||||
"notes": "Production thresholds – strict SLA targets"
|
||||
},
|
||||
"local": {
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"stages": {
|
||||
"rampUp": 5,
|
||||
"steady": 10,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 3000,
|
||||
"login_p95": 2000,
|
||||
"dashboard_p95": 5000,
|
||||
"crud_write_p95": 3000,
|
||||
"crud_read_p95": 2000,
|
||||
"error_rate": 0.10
|
||||
},
|
||||
"notes": "Local dev – generous thresholds for single-machine testing"
|
||||
}
|
||||
}
|
||||
11
load-tests/config/user-pool.csv
Normal file
11
load-tests/config/user-pool.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
email,password,orgId,role
|
||||
loadtest-treasurer-01@hoaledgeriq.test,LoadTest!Pass01,org-uuid-placeholder-1,treasurer
|
||||
loadtest-treasurer-02@hoaledgeriq.test,LoadTest!Pass02,org-uuid-placeholder-1,treasurer
|
||||
loadtest-admin-01@hoaledgeriq.test,LoadTest!Pass03,org-uuid-placeholder-1,admin
|
||||
loadtest-admin-02@hoaledgeriq.test,LoadTest!Pass04,org-uuid-placeholder-2,admin
|
||||
loadtest-president-01@hoaledgeriq.test,LoadTest!Pass05,org-uuid-placeholder-2,president
|
||||
loadtest-manager-01@hoaledgeriq.test,LoadTest!Pass06,org-uuid-placeholder-2,manager
|
||||
loadtest-member-01@hoaledgeriq.test,LoadTest!Pass07,org-uuid-placeholder-1,member_at_large
|
||||
loadtest-viewer-01@hoaledgeriq.test,LoadTest!Pass08,org-uuid-placeholder-1,viewer
|
||||
loadtest-homeowner-01@hoaledgeriq.test,LoadTest!Pass09,org-uuid-placeholder-2,homeowner
|
||||
loadtest-homeowner-02@hoaledgeriq.test,LoadTest!Pass10,org-uuid-placeholder-2,homeowner
|
||||
|
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginDuration = new Trend('login_duration', true);
|
||||
const refreshDuration = new Trend('refresh_duration', true);
|
||||
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||
const profileDuration = new Trend('profile_duration', true);
|
||||
const loginFailures = new Counter('login_failures');
|
||||
const authErrors = new Rate('auth_error_rate');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test user pool – parameterized from CSV
|
||||
// ---------------------------------------------------------------------------
|
||||
const users = new SharedArray('users', function () {
|
||||
const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header
|
||||
return lines
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((line) => {
|
||||
const [email, password, orgId, role] = line.split(',');
|
||||
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment config
|
||||
// ---------------------------------------------------------------------------
|
||||
const ENV = JSON.parse(open('../config/environments.json'));
|
||||
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||
const BASE = CONF.baseUrl;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// k6 options – ramp-up / steady / ramp-down
|
||||
// ---------------------------------------------------------------------------
|
||||
export const options = {
|
||||
scenarios: {
|
||||
auth_dashboard: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '1m', target: CONF.stages.rampUp }, // ramp-up
|
||||
{ duration: '5m', target: CONF.stages.steady }, // steady state
|
||||
{ duration: '1m', target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||
login_duration: [`p(95)<${CONF.thresholds.login_p95}`],
|
||||
dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`],
|
||||
auth_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function authHeaders(accessToken) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonPost(url, body, params = {}) {
|
||||
return http.post(url, JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) },
|
||||
tags: params.tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default VU function – login → dashboard journey
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function () {
|
||||
const user = users[__VU % users.length];
|
||||
|
||||
let accessToken = null;
|
||||
|
||||
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||
group('01_login', () => {
|
||||
const res = jsonPost(`${BASE}/api/auth/login`, {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}, { tags: { name: 'POST /api/auth/login' } });
|
||||
|
||||
loginDuration.add(res.timings.duration);
|
||||
|
||||
const ok = check(res, {
|
||||
'login status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
'login returns accessToken': (r) => {
|
||||
try { return !!JSON.parse(r.body).accessToken; } catch { return false; }
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
loginFailures.add(1);
|
||||
authErrors.add(1);
|
||||
return; // abort journey – cannot continue without token
|
||||
}
|
||||
authErrors.add(0);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
accessToken = body.accessToken;
|
||||
});
|
||||
|
||||
if (!accessToken) return; // guard
|
||||
|
||||
sleep(0.5); // think-time between login and dashboard load
|
||||
|
||||
// ── 2. Fetch profile ────────────────────────────────────────────────
|
||||
group('02_profile', () => {
|
||||
const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken));
|
||||
profileDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'profile status 200': (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── 3. Dashboard KPIs ───────────────────────────────────────────────
|
||||
group('03_dashboard', () => {
|
||||
const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken));
|
||||
dashboardDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'dashboard status 200': (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── 4. Parallel dashboard widgets (batch) ───────────────────────────
|
||||
group('04_dashboard_widgets', () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const fromDate = `${year}-01-01`;
|
||||
const toDate = now.toISOString().slice(0, 10);
|
||||
|
||||
const responses = http.batch([
|
||||
['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)],
|
||||
]);
|
||||
|
||||
responses.forEach((res, i) => {
|
||||
check(res, {
|
||||
[`widget_${i} status 200`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 5. Refresh token ───────────────────────────────────────────────
|
||||
group('05_refresh_token', () => {
|
||||
const res = http.post(`${BASE}/api/auth/refresh`, null, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'POST /api/auth/refresh' },
|
||||
});
|
||||
refreshDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'refresh status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 6. Logout ──────────────────────────────────────────────────────
|
||||
group('06_logout', () => {
|
||||
const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken));
|
||||
check(res, {
|
||||
'logout status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1); // pacing between iterations
|
||||
}
|
||||
377
load-tests/scenarios/crud-flow.js
Normal file
377
load-tests/scenarios/crud-flow.js
Normal file
@@ -0,0 +1,377 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginDuration = new Trend('crud_login_duration', true);
|
||||
const createDuration = new Trend('crud_create_duration', true);
|
||||
const readDuration = new Trend('crud_read_duration', true);
|
||||
const updateDuration = new Trend('crud_update_duration', true);
|
||||
const deleteDuration = new Trend('crud_delete_duration', true);
|
||||
const listDuration = new Trend('crud_list_duration', true);
|
||||
const crudErrors = new Rate('crud_error_rate');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test user pool
|
||||
// ---------------------------------------------------------------------------
|
||||
const users = new SharedArray('users', function () {
|
||||
const lines = open('../config/user-pool.csv').split('\n').slice(1);
|
||||
return lines
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((line) => {
|
||||
const [email, password, orgId, role] = line.split(',');
|
||||
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment config
|
||||
// ---------------------------------------------------------------------------
|
||||
const ENV = JSON.parse(open('../config/environments.json'));
|
||||
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||
const BASE = CONF.baseUrl;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// k6 options
|
||||
// ---------------------------------------------------------------------------
|
||||
export const options = {
|
||||
scenarios: {
|
||||
crud_flow: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '1m', target: CONF.stages.rampUp },
|
||||
{ duration: '5m', target: CONF.stages.steady },
|
||||
{ duration: '1m', target: 0 },
|
||||
],
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||
crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||
crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||
crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||
crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||
crud_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function authHeaders(token) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonPost(url, body, token, tags) {
|
||||
return http.post(url, JSON.stringify(body), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
function jsonPut(url, body, token, tags) {
|
||||
return http.put(url, JSON.stringify(body), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
function login(user) {
|
||||
const res = http.post(
|
||||
`${BASE}/api/auth/login`,
|
||||
JSON.stringify({ email: user.email, password: user.password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } },
|
||||
);
|
||||
loginDuration.add(res.timings.duration);
|
||||
if (res.status !== 200 && res.status !== 201) return null;
|
||||
try { return JSON.parse(res.body).accessToken; } catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VU function – CRUD journey across core entities
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function () {
|
||||
const user = users[__VU % users.length];
|
||||
|
||||
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||
const accessToken = login(user);
|
||||
if (!accessToken) {
|
||||
crudErrors.add(1);
|
||||
return;
|
||||
}
|
||||
crudErrors.add(0);
|
||||
sleep(0.5);
|
||||
|
||||
// ── 2. Units CRUD ───────────────────────────────────────────────────
|
||||
let unitId = null;
|
||||
group('units_crud', () => {
|
||||
// List
|
||||
group('list_units', () => {
|
||||
const res = http.get(`${BASE}/api/units`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list units 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Create
|
||||
group('create_unit', () => {
|
||||
const payload = {
|
||||
unitNumber: `LT-${__VU}-${Date.now()}`,
|
||||
address: `${__VU} Load Test Lane`,
|
||||
ownerName: `Load Tester ${__VU}`,
|
||||
ownerEmail: `lt-${__VU}@loadtest.local`,
|
||||
squareFeet: 1200,
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create unit 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { unitId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Read
|
||||
if (unitId) {
|
||||
group('read_unit', () => {
|
||||
const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Update
|
||||
group('update_unit', () => {
|
||||
const res = jsonPut(`${BASE}/api/units/${unitId}`, {
|
||||
ownerName: `Updated Tester ${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/units/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Delete
|
||||
group('delete_unit', () => {
|
||||
const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'delete unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 3. Vendors CRUD ─────────────────────────────────────────────────
|
||||
let vendorId = null;
|
||||
group('vendors_crud', () => {
|
||||
group('list_vendors', () => {
|
||||
const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list vendors 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('create_vendor', () => {
|
||||
const payload = {
|
||||
name: `LT Vendor ${__VU}-${Date.now()}`,
|
||||
email: `vendor-${__VU}@loadtest.local`,
|
||||
phone: '555-0100',
|
||||
category: 'maintenance',
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create vendor 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (vendorId) {
|
||||
group('read_vendor', () => {
|
||||
const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read vendor 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('update_vendor', () => {
|
||||
const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, {
|
||||
name: `Updated Vendor ${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/vendors/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update vendor 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 4. Journal Entries workflow ─────────────────────────────────────
|
||||
let journalEntryId = null;
|
||||
group('journal_entries', () => {
|
||||
group('list_journal_entries', () => {
|
||||
const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Fetch accounts first so we can build a valid entry
|
||||
let accounts = [];
|
||||
group('fetch_accounts_for_je', () => {
|
||||
const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken));
|
||||
check(res, { 'list accounts 200': (r) => r.status === 200 });
|
||||
try { accounts = JSON.parse(res.body); } catch { /* noop */ }
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (Array.isArray(accounts) && accounts.length >= 2) {
|
||||
group('create_journal_entry', () => {
|
||||
const payload = {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
memo: `Load test JE VU-${__VU}`,
|
||||
type: 'standard',
|
||||
lines: [
|
||||
{ accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' },
|
||||
{ accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' },
|
||||
],
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create JE 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (journalEntryId) {
|
||||
group('read_journal_entry', () => {
|
||||
const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Post (finalize) the journal entry
|
||||
group('post_journal_entry', () => {
|
||||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken));
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'post JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Void the journal entry (cleanup)
|
||||
group('void_journal_entry', () => {
|
||||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'void JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 5. Payments CRUD ────────────────────────────────────────────────
|
||||
let paymentId = null;
|
||||
group('payments_crud', () => {
|
||||
group('list_payments', () => {
|
||||
const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list payments 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('create_payment', () => {
|
||||
const payload = {
|
||||
amount: 150.00,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
method: 'check',
|
||||
memo: `Load test payment VU-${__VU}`,
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create payment 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (paymentId) {
|
||||
group('read_payment', () => {
|
||||
const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('update_payment', () => {
|
||||
const res = jsonPut(`${BASE}/api/payments/${paymentId}`, {
|
||||
memo: `Updated payment VU-${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/payments/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('delete_payment', () => {
|
||||
const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'delete payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 6. Reports (read-heavy) ─────────────────────────────────────────
|
||||
group('reports_read', () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const toDate = now.toISOString().slice(0, 10);
|
||||
const fromDate = `${year}-01-01`;
|
||||
|
||||
const responses = http.batch([
|
||||
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)],
|
||||
]);
|
||||
|
||||
responses.forEach((res, i) => {
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { [`report_${i} status 200`]: (r) => r.status === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1); // pacing
|
||||
}
|
||||
24
package.json
Normal file
24
package.json
Normal 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
127
playwright.config.ts
Normal 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',
|
||||
},
|
||||
}),
|
||||
});
|
||||
140
tests/api/accounts.api.spec.ts
Normal file
140
tests/api/accounts.api.spec.ts
Normal 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
124
tests/api/auth.api.spec.ts
Normal 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
50
tests/auth.setup.ts
Normal 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
97
tests/e2e/auth.spec.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
tests/e2e/dashboard.spec.ts
Normal file
76
tests/e2e/dashboard.spec.ts
Normal 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
53
tests/e2e/visual.spec.ts
Normal 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
108
tests/fixtures/auth.fixture.ts
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Authentication fixture for E2E tests.
|
||||
*
|
||||
* Provides helpers to authenticate via the API and manage JWT tokens.
|
||||
* The auth.setup.ts project uses this to create persistent auth state
|
||||
* that other tests reuse (avoiding login in every test).
|
||||
*/
|
||||
|
||||
import { type APIRequestContext, type BrowserContext } from '@playwright/test';
|
||||
import { TEST_USERS } from './test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
user: Record<string, unknown>;
|
||||
organizations: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login via the API and return tokens.
|
||||
* Uses Playwright's request context (no browser needed).
|
||||
*/
|
||||
export async function apiLogin(
|
||||
request: APIRequestContext,
|
||||
credentials: { email: string; password: string } = TEST_USERS.treasurer,
|
||||
): Promise<AuthTokens> {
|
||||
const response = await request.post(`${API_BASE}/auth/login`, {
|
||||
data: {
|
||||
email: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Login failed (${response.status()}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific organization after login.
|
||||
*/
|
||||
export async function apiSwitchOrg(
|
||||
request: APIRequestContext,
|
||||
accessToken: string,
|
||||
organizationId: string,
|
||||
): Promise<AuthTokens> {
|
||||
const response = await request.post(`${API_BASE}/auth/switch-org`, {
|
||||
data: { organizationId },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Switch org failed (${response.status()}): ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an authenticated API request context with a Bearer token.
|
||||
* Useful for API-level tests that don't need a browser.
|
||||
*/
|
||||
export function authHeaders(accessToken: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject auth state into a browser context's localStorage.
|
||||
* Matches the frontend's Zustand authStore shape.
|
||||
*/
|
||||
export async function injectAuthState(
|
||||
context: BrowserContext,
|
||||
tokens: AuthTokens,
|
||||
orgId?: string,
|
||||
): Promise<void> {
|
||||
const baseURL = process.env.BASE_URL || 'http://localhost';
|
||||
|
||||
// The frontend uses Zustand with persist middleware in localStorage
|
||||
// under key 'auth-storage'. We inject it so the frontend thinks
|
||||
// the user is already logged in.
|
||||
const selectedOrg = orgId
|
||||
? tokens.organizations.find((o: any) => o.id === orgId)
|
||||
: tokens.organizations[0];
|
||||
|
||||
const authState = {
|
||||
state: {
|
||||
token: tokens.accessToken,
|
||||
user: tokens.user,
|
||||
organizations: tokens.organizations,
|
||||
currentOrg: selectedOrg
|
||||
? { id: (selectedOrg as any).id, name: (selectedOrg as any).name, role: (selectedOrg as any).role }
|
||||
: null,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
|
||||
await context.addInitScript((authData) => {
|
||||
window.localStorage.setItem('auth-storage', JSON.stringify(authData));
|
||||
}, authState);
|
||||
}
|
||||
87
tests/fixtures/base.fixture.ts
vendored
Normal file
87
tests/fixtures/base.fixture.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Base Playwright fixture that combines auth + DB helpers.
|
||||
*
|
||||
* Extends the default `test` object so every test file can
|
||||
* import from here and get typed access to fixtures.
|
||||
*
|
||||
* Usage:
|
||||
* import { test, expect } from '../fixtures/base.fixture';
|
||||
* test('my test', async ({ authedPage, apiContext }) => { ... });
|
||||
*/
|
||||
|
||||
import { test as base, type Page, type APIRequestContext } from '@playwright/test';
|
||||
import { apiLogin, apiSwitchOrg, authHeaders, type AuthTokens } from './auth.fixture';
|
||||
import { testDb } from './db.fixture';
|
||||
import { TEST_USERS } from './test-data';
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
|
||||
|
||||
type TestFixtures = {
|
||||
/** Pre-authenticated API request context with Bearer token */
|
||||
authedRequest: APIRequestContext & { _tokens: AuthTokens };
|
||||
/** Access token string for manual header construction */
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
/** Shared database connection (one per worker) */
|
||||
db: typeof testDb;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
/**
|
||||
* Worker-scoped database connection.
|
||||
* Connects once per worker, disconnects when the worker exits.
|
||||
*/
|
||||
db: [
|
||||
async ({}, use) => {
|
||||
await testDb.connect();
|
||||
await use(testDb);
|
||||
await testDb.disconnect();
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
/**
|
||||
* Per-test authenticated API request context.
|
||||
* Logs in as the default test user and attaches the Bearer token.
|
||||
*/
|
||||
authedRequest: async ({ playwright }, use) => {
|
||||
const requestContext = await playwright.request.newContext({
|
||||
baseURL: API_BASE,
|
||||
});
|
||||
|
||||
const tokens = await apiLogin(requestContext, TEST_USERS.treasurer);
|
||||
|
||||
// If user belongs to orgs, switch to the first one
|
||||
let finalTokens = tokens;
|
||||
if (tokens.organizations?.length > 0) {
|
||||
const orgId = (tokens.organizations[0] as any).id;
|
||||
try {
|
||||
finalTokens = await apiSwitchOrg(requestContext, tokens.accessToken, orgId);
|
||||
} catch {
|
||||
// switch-org may not be needed if token already scoped
|
||||
finalTokens = tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new context with the auth header baked in
|
||||
const authedContext = await playwright.request.newContext({
|
||||
baseURL: API_BASE,
|
||||
extraHTTPHeaders: authHeaders(finalTokens.accessToken),
|
||||
});
|
||||
|
||||
// Attach tokens for tests that need them
|
||||
(authedContext as any)._tokens = finalTokens;
|
||||
|
||||
await use(authedContext as any);
|
||||
await authedContext.dispose();
|
||||
await requestContext.dispose();
|
||||
},
|
||||
|
||||
accessToken: async ({ authedRequest }, use) => {
|
||||
await use((authedRequest as any)._tokens.accessToken);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
124
tests/fixtures/db.fixture.ts
vendored
Normal file
124
tests/fixtures/db.fixture.ts
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Database fixture for E2E tests.
|
||||
*
|
||||
* Provides helpers to seed and clean up test data in Postgres.
|
||||
* Uses the `pg` driver directly (same driver the backend uses)
|
||||
* to avoid coupling tests to the backend's TypeORM setup.
|
||||
*
|
||||
* Usage in tests:
|
||||
* import { testDb } from '../fixtures/db.fixture';
|
||||
* test.beforeAll(async () => { await testDb.connect(); });
|
||||
* test.afterAll(async () => { await testDb.cleanup(); await testDb.disconnect(); });
|
||||
*/
|
||||
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { TEST_PREFIX } from './test-data';
|
||||
|
||||
const TEST_DB_URL =
|
||||
process.env.TEST_DB_URL ||
|
||||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
||||
|
||||
class TestDatabase {
|
||||
private pool: Pool | null = null;
|
||||
|
||||
/** Connect to the test database */
|
||||
async connect(): Promise<void> {
|
||||
this.pool = new Pool({ connectionString: TEST_DB_URL, max: 5 });
|
||||
// Verify connectivity
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('SELECT 1');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a client from the pool */
|
||||
async getClient(): Promise<PoolClient> {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
return this.pool.connect();
|
||||
}
|
||||
|
||||
/** Execute a query */
|
||||
async query(sql: string, params?: unknown[]) {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
return this.pool.query(sql, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all test-created data.
|
||||
* Removes rows that match the TEST_PREFIX in name/description/memo fields.
|
||||
* This runs within a transaction so it's atomic.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
if (!this.pool) return;
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Clean up test data from tenant schemas.
|
||||
// We look for the e2e test org schema if it exists.
|
||||
const schemas = await client.query(
|
||||
`SELECT schema_name FROM shared.organizations
|
||||
WHERE name LIKE '${TEST_PREFIX}%' OR schema_name = 'e2e_test_hoa'`,
|
||||
);
|
||||
|
||||
for (const row of schemas.rows) {
|
||||
const schema = row.schema_name;
|
||||
// Delete test journal entries, accounts, etc. in tenant schema
|
||||
await client.query(
|
||||
`DELETE FROM "${schema}".journal_entries WHERE memo LIKE $1`,
|
||||
[`${TEST_PREFIX}%`],
|
||||
).catch(() => {}); // Table may not exist
|
||||
await client.query(
|
||||
`DELETE FROM "${schema}".accounts WHERE name LIKE $1`,
|
||||
[`${TEST_PREFIX}%`],
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// Clean up test users from shared schema
|
||||
await client.query(
|
||||
`DELETE FROM shared.users WHERE email LIKE 'e2e-%@test.hoaledgeriq.com'`,
|
||||
).catch(() => {});
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Test cleanup failed:', err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the minimum data needed for authenticated test flows:
|
||||
* - A test organization (if not exists)
|
||||
* - A test user with known credentials (if not exists)
|
||||
* - Associates user to org with the given role
|
||||
*
|
||||
* This is idempotent — safe to call multiple times.
|
||||
*/
|
||||
async seedTestUser(user: {
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
if (!this.pool) throw new Error('TestDatabase not connected');
|
||||
|
||||
// This is done via API calls in auth.setup.ts instead of direct SQL,
|
||||
// because the backend handles password hashing, schema creation, etc.
|
||||
// This method is available for advanced scenarios that need direct DB access.
|
||||
console.log(`[TestDB] Seed user ${user.email} — use API-based seeding in auth.setup.ts`);
|
||||
}
|
||||
|
||||
/** Disconnect from the database */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const testDb = new TestDatabase();
|
||||
48
tests/fixtures/test-data.ts
vendored
Normal file
48
tests/fixtures/test-data.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared test data constants for E2E and API tests.
|
||||
*
|
||||
* Credentials match what the DB seed fixture creates.
|
||||
* Money values are in cents (matching the backend convention).
|
||||
*/
|
||||
|
||||
export const TEST_USERS = {
|
||||
treasurer: {
|
||||
email: process.env.TEST_USER_EMAIL || 'e2e-treasurer@test.hoaledgeriq.com',
|
||||
password: process.env.TEST_USER_PASSWORD || 'TestPass123!',
|
||||
role: 'treasurer',
|
||||
fullName: 'E2E Test Treasurer',
|
||||
},
|
||||
viewer: {
|
||||
email: 'e2e-viewer@test.hoaledgeriq.com',
|
||||
password: 'TestPass123!',
|
||||
role: 'viewer',
|
||||
fullName: 'E2E Test Viewer',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const TEST_ORG = {
|
||||
name: 'E2E Test HOA',
|
||||
schemaName: 'e2e_test_hoa',
|
||||
} as const;
|
||||
|
||||
/** Sample account used for CRUD tests */
|
||||
export const SAMPLE_ACCOUNT = {
|
||||
name: 'E2E Operating Checking',
|
||||
accountType: 'asset',
|
||||
fundType: 'operating',
|
||||
number: '1010',
|
||||
description: 'E2E test operating account',
|
||||
} as const;
|
||||
|
||||
/** Sample journal entry for write-path tests */
|
||||
export const SAMPLE_JOURNAL_ENTRY = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
memo: 'E2E test journal entry',
|
||||
lines: [
|
||||
{ accountId: '', debit: 100_00, credit: 0 },
|
||||
{ accountId: '', credit: 100_00, debit: 0 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Unique prefix for test-created data — makes cleanup queries simple */
|
||||
export const TEST_PREFIX = 'E2E_' as const;
|
||||
82
tests/page-objects/AccountsPage.ts
Normal file
82
tests/page-objects/AccountsPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
73
tests/page-objects/BasePage.ts
Normal file
73
tests/page-objects/BasePage.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
tests/page-objects/DashboardPage.ts
Normal file
56
tests/page-objects/DashboardPage.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
85
tests/page-objects/LoginPage.ts
Normal file
85
tests/page-objects/LoginPage.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
tests/page-objects/index.ts
Normal file
10
tests/page-objects/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user