feat: add k6 load testing suite, NRQL query library, and CLAUDE.md

Add comprehensive load testing infrastructure:
- k6 auth-dashboard flow (login → profile → dashboard KPIs → widgets → refresh → logout)
- k6 CRUD flow (units, vendors, journal entries, payments, reports)
- Environment configs with staging/production/local thresholds
- Parameterized user pool CSV matching app roles
- New Relic NRQL query library (25+ queries for perf analysis)
- Empty baseline.json structure for all tested endpoints
- CLAUDE.md documenting full stack, auth, route map, and conventions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:49:22 -04:00
parent 66e2f87a96
commit 06bc0181f8
7 changed files with 1270 additions and 0 deletions

229
CLAUDE.md Normal file
View 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

View 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 }
}
}

View 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;

View 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"
}
}

View 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
1 email password orgId role
2 loadtest-treasurer-01@hoaledgeriq.test LoadTest!Pass01 org-uuid-placeholder-1 treasurer
3 loadtest-treasurer-02@hoaledgeriq.test LoadTest!Pass02 org-uuid-placeholder-1 treasurer
4 loadtest-admin-01@hoaledgeriq.test LoadTest!Pass03 org-uuid-placeholder-1 admin
5 loadtest-admin-02@hoaledgeriq.test LoadTest!Pass04 org-uuid-placeholder-2 admin
6 loadtest-president-01@hoaledgeriq.test LoadTest!Pass05 org-uuid-placeholder-2 president
7 loadtest-manager-01@hoaledgeriq.test LoadTest!Pass06 org-uuid-placeholder-2 manager
8 loadtest-member-01@hoaledgeriq.test LoadTest!Pass07 org-uuid-placeholder-1 member_at_large
9 loadtest-viewer-01@hoaledgeriq.test LoadTest!Pass08 org-uuid-placeholder-1 viewer
10 loadtest-homeowner-01@hoaledgeriq.test LoadTest!Pass09 org-uuid-placeholder-2 homeowner
11 loadtest-homeowner-02@hoaledgeriq.test LoadTest!Pass10 org-uuid-placeholder-2 homeowner

View 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
}

View 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
}