Compare commits
2 Commits
claude/bea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae856bfb2f | |||
| 31f8274b8d |
229
CLAUDE.md
229
CLAUDE.md
@@ -1,229 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
{
|
|
||||||
"_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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
-- =============================================================================
|
|
||||||
-- 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;
|
|
||||||
183
load-tests/auth-dashboard-flow.js
Normal file
183
load-tests/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Auth + Dashboard Load Test
|
||||||
|
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
|
||||||
|
*
|
||||||
|
* Covers the highest-frequency production flow: a treasurer or admin
|
||||||
|
* opening the app, loading the dashboard, and reviewing financial reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const loginDuration = new Trend('login_duration', true);
|
||||||
|
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||||
|
const refreshDuration = new Trend('refresh_duration', true);
|
||||||
|
const authErrorRate = new Rate('auth_error_rate');
|
||||||
|
const dashboardErrorRate = new Rate('dashboard_error_rate');
|
||||||
|
const tokenRefreshCount = new Counter('token_refresh_count');
|
||||||
|
|
||||||
|
// ── User pool ────────────────────────────────────────────────────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1) // skip header row
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.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 = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
auth_dashboard: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 20 }, // warm up
|
||||||
|
{ duration: '5m', target: 100 }, // ramp to target load
|
||||||
|
{ duration: '5m', target: 100 }, // sustained load
|
||||||
|
{ duration: '3m', target: 200 }, // peak spike
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
// Latency targets per environment (overridden by environments.json)
|
||||||
|
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
|
||||||
|
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
|
||||||
|
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function authHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
loginDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
'has orgId in body': r => r.json('user.orgId') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1.5); // think time – user lands on dashboard
|
||||||
|
|
||||||
|
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
|
||||||
|
group('dashboard:load', () => {
|
||||||
|
const requests = {
|
||||||
|
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
|
||||||
|
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
|
||||||
|
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
|
||||||
|
profile: ['GET', `${BASE_URL}/api/auth/profile`],
|
||||||
|
accounts: ['GET', `${BASE_URL}/api/accounts`],
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = http.batch(
|
||||||
|
Object.entries(requests).map(([name, [method, url]]) => ({
|
||||||
|
method, url,
|
||||||
|
params: { headers: authHeaders(accessToken), tags: { name } },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
const name = Object.keys(requests)[i];
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: name });
|
||||||
|
const ok = check(res, {
|
||||||
|
[`${name} 200`]: r => r.status === 200,
|
||||||
|
[`${name} has body`]: r => r.body && r.body.length > 0,
|
||||||
|
});
|
||||||
|
if (!ok) allOk = false;
|
||||||
|
});
|
||||||
|
dashboardErrorRate.add(!allOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reads the dashboard
|
||||||
|
|
||||||
|
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
|
||||||
|
// In the load test we trigger it early to validate the refresh path under load
|
||||||
|
group('auth:refresh', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/refresh`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers: authHeaders(accessToken),
|
||||||
|
tags: { name: 'refresh' },
|
||||||
|
// k6 sends the httpOnly cookie from the jar automatically
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshDuration.add(res.timings.duration);
|
||||||
|
tokenRefreshCount.add(1);
|
||||||
|
const ok = check(res, {
|
||||||
|
'refresh 200': r => r.status === 200,
|
||||||
|
'new access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (ok) accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Drill into one report (cash-flow forecast – typically slowest) ────
|
||||||
|
group('dashboard:drill', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/reports/cash-flow-forecast`,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
|
||||||
|
);
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
|
||||||
|
dashboardErrorRate.add(res.status !== 200);
|
||||||
|
check(res, { 'forecast 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// ── 5. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
45
load-tests/baseline.json
Normal file
45
load-tests/baseline.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
|
||||||
|
"last_updated": "YYYY-MM-DD",
|
||||||
|
"last_run_cycle": 0,
|
||||||
|
"units": "milliseconds"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"journal_entries": {
|
||||||
|
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
|
|
||||||
|
259
load-tests/crud-flow.js
Normal file
259
load-tests/crud-flow.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Core CRUD Workflow Load Test
|
||||||
|
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
|
||||||
|
* Record Payment → View Accounts → Budget vs Actual → Logout
|
||||||
|
*
|
||||||
|
* This scenario exercises write-heavy paths gated by WriteAccessGuard
|
||||||
|
* and the TenantMiddleware schema-switch. Run this alongside
|
||||||
|
* auth-dashboard-flow.js to simulate a realistic mixed workload.
|
||||||
|
*
|
||||||
|
* Role used: treasurer (has full write access, most common power user)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate } from 'k6/metrics';
|
||||||
|
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const journalEntryDuration = new Trend('journal_entry_duration', true);
|
||||||
|
const invoiceDuration = new Trend('invoice_duration', true);
|
||||||
|
const paymentDuration = new Trend('payment_duration', true);
|
||||||
|
const accountsReadDuration = new Trend('accounts_read_duration', true);
|
||||||
|
const budgetDuration = new Trend('budget_vs_actual_duration',true);
|
||||||
|
const crudErrorRate = new Rate('crud_error_rate');
|
||||||
|
const writeGuardErrorRate = new Rate('write_guard_error_rate');
|
||||||
|
|
||||||
|
// ── User pool (treasurer + admin roles only for write access) ────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
})
|
||||||
|
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Environment config ───────────────────────────────────────────────────────
|
||||||
|
const ENV = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
crud_workflow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 10 }, // warm up (writes need more care)
|
||||||
|
{ duration: '5m', target: 50 }, // ramp to target
|
||||||
|
{ duration: '5m', target: 50 }, // sustained
|
||||||
|
{ duration: '3m', target: 100 }, // peak
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
|
||||||
|
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function jsonHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYear() {
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
|
||||||
|
let debitAccountId = null;
|
||||||
|
let creditAccountId = null;
|
||||||
|
|
||||||
|
group('accounts:list', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'accounts 200': r => r.status === 200,
|
||||||
|
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
const accounts = res.json();
|
||||||
|
// Pick first two distinct accounts for the journal entry
|
||||||
|
debitAccountId = accounts[0]?.id;
|
||||||
|
creditAccountId = accounts[1]?.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 3. Create journal entry (draft) ────────────────────────────────────
|
||||||
|
let journalEntryId = null;
|
||||||
|
|
||||||
|
group('journal:create', () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
description: `Load test entry ${uuidv4().slice(0, 8)}`,
|
||||||
|
lines: [
|
||||||
|
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
|
||||||
|
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
// Watch for WriteAccessGuard rejections (403)
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'journal create 201': r => r.status === 201,
|
||||||
|
'journal has id': r => r.json('id') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (ok) journalEntryId = res.json('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Post the journal entry ────────────────────────────────────────────
|
||||||
|
if (journalEntryId) {
|
||||||
|
group('journal:post', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
|
||||||
|
);
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, { 'journal post 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
sleep(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Generate invoice preview ─────────────────────────────────────────
|
||||||
|
let invoicePreviewOk = false;
|
||||||
|
group('invoice:preview', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/invoices/generate-preview`,
|
||||||
|
JSON.stringify({ period: currentYear() }),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
|
||||||
|
);
|
||||||
|
invoiceDuration.add(res.timings.duration);
|
||||||
|
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!invoicePreviewOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reviews invoice preview
|
||||||
|
|
||||||
|
// ── 6. Create a payment record ───────────────────────────────────────────
|
||||||
|
group('payment:create', () => {
|
||||||
|
const payload = {
|
||||||
|
amount: 150.00,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
method: 'check',
|
||||||
|
description: `Load test payment ${uuidv4().slice(0, 8)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/payments`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
|
||||||
|
);
|
||||||
|
paymentDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
|
||||||
|
group('budget:vs-actual', () => {
|
||||||
|
const year = currentYear();
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/budgets/${year}/vs-actual`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
|
||||||
|
);
|
||||||
|
budgetDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 8. Trial balance read ────────────────────────────────────────────────
|
||||||
|
group('accounts:trial-balance', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts/trial-balance`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'trial balance 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 9. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
117
load-tests/cycle-template.md
Normal file
117
load-tests/cycle-template.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# HOALedgerIQ – Load Test Improvement Report
|
||||||
|
**Cycle:** 001
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Test window:** HH:MM – HH:MM UTC
|
||||||
|
**Environments:** Staging (`staging.hoaledgeriq.com`)
|
||||||
|
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
|
||||||
|
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
|
||||||
|
**New Relic app:** `HOALedgerIQ_App`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
|
||||||
|
|
||||||
|
**Threshold breaches this cycle:**
|
||||||
|
|
||||||
|
| Metric | Target | Actual | Status |
|
||||||
|
|--------|--------|--------|--------|
|
||||||
|
| login p95 | < 300ms | — | 🔴 / 🟢 |
|
||||||
|
| dashboard p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 |
|
||||||
|
| error rate | < 1% | — | 🔴 / 🟢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 🔴 P0 – Fix Before Next Deploy
|
||||||
|
|
||||||
|
#### Finding 001 – [Short title]
|
||||||
|
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
|
||||||
|
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
|
||||||
|
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_
|
||||||
|
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
|
||||||
|
- **Recommended fix:**
|
||||||
|
```typescript
|
||||||
|
// BEFORE – N+1: one query per component
|
||||||
|
for (const component of components) {
|
||||||
|
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER – batch load with WHERE IN
|
||||||
|
const actuals = await this.actualsRepo.findBy({
|
||||||
|
componentId: In(components.map(c => c.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- **Expected improvement:** ~70% latency reduction on this endpoint
|
||||||
|
- **Effort:** Low (1–2 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 P1 – Fix Within This Sprint
|
||||||
|
|
||||||
|
#### Finding 002 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **New Relic evidence:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **File:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Expected improvement:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
#### Finding 003 – [Short title]
|
||||||
|
- _(same structure)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 P2 – Backlog
|
||||||
|
|
||||||
|
#### Finding 004 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Net — Re-Test Criteria
|
||||||
|
|
||||||
|
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
|
||||||
|
|
||||||
|
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|
||||||
|
|----------|-------------|------------|-------------|
|
||||||
|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
|
||||||
|
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
|
||||||
|
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
|
||||||
|
|
||||||
|
> **Claude Code update command (run after confirming fixes):**
|
||||||
|
> ```bash
|
||||||
|
> claude "Update load-tests/analysis/baseline.json with the p95 values from
|
||||||
|
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
|
||||||
|
> load-tests/config/environments.json staging block to match. Do not loosen
|
||||||
|
> any threshold that already passes."
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Delta
|
||||||
|
|
||||||
|
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|
||||||
|
|----------|--------------|--------------|---|
|
||||||
|
| _(populated after first run)_ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Observations
|
||||||
|
|
||||||
|
- _Any anomalies, flaky tests, or infrastructure events during the run_
|
||||||
|
- _Redis / BullMQ queue depth observations_
|
||||||
|
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
|
||||||
|
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._
|
||||||
38
load-tests/environments.json
Normal file
38
load-tests/environments.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"local": {
|
||||||
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 500,
|
||||||
|
"refresh_p95": 300,
|
||||||
|
"read_p95": 1000,
|
||||||
|
"write_p95": 1500,
|
||||||
|
"dashboard_p95": 1500,
|
||||||
|
"global_p99": 3000,
|
||||||
|
"error_rate": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 300,
|
||||||
|
"refresh_p95": 200,
|
||||||
|
"read_p95": 800,
|
||||||
|
"write_p95": 1200,
|
||||||
|
"dashboard_p95": 1000,
|
||||||
|
"global_p99": 2000,
|
||||||
|
"error_rate": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"baseUrl": "https://app.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 200,
|
||||||
|
"refresh_p95": 150,
|
||||||
|
"read_p95": 500,
|
||||||
|
"write_p95": 800,
|
||||||
|
"dashboard_p95": 700,
|
||||||
|
"global_p99": 1500,
|
||||||
|
"error_rate": 0.005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
274
load-tests/nrql-queries.sql
Normal file
274
load-tests/nrql-queries.sql
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- HOALedgerIQ – New Relic NRQL Query Library
|
||||||
|
-- App name: HOALedgerIQ_App
|
||||||
|
-- Usage: Run in New Relic Query Builder. Replace time windows as needed.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 1: OVERVIEW HEALTH ────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 1.1 Apdex score over last test window
|
||||||
|
SELECT apdex(duration, t: 0.5) AS 'Apdex'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.2 Overall throughput (requests per minute)
|
||||||
|
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.3 Error rate over time
|
||||||
|
SELECT percentage(count(*), WHERE error IS true) AS 'Error %'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 2: LATENCY BY ENDPOINT ────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 2.1 p50 / p95 / p99 latency by transaction name
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 2.2 Slowest endpoints (p95) during load test window
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY percentile(duration, 95) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 2.3 Auth endpoint latency breakdown
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%auth%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.4 Report endpoint latency (typically slowest reads)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.5 Write endpoint latency (journal-entries, payments, invoices)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.6 Latency heatmap over time for dashboard load
|
||||||
|
SELECT histogram(duration, width: 100, buckets: 20)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports/dashboard%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 3: DATABASE PERFORMANCE ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 3.1 Slowest database queries (top 20)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.2 Database call count by operation type
|
||||||
|
SELECT count(*)
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET operation
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.3 N+1 detection – high-call-count queries
|
||||||
|
SELECT count(*) AS 'call count', average(duration) AS 'avg ms'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.4 DB time as % of total transaction time (per endpoint)
|
||||||
|
SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND databaseDuration IS NOT NULL
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(databaseDuration) / average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion)
|
||||||
|
SELECT count(*) AS 'slow queries (>500ms)'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND duration > 0.5
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware)
|
||||||
|
SELECT average(duration) AS 'avg ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/refresh%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 4: ERROR ANALYSIS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 4.1 All errors by class and message
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET errorClass, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 4.2 Error rate by HTTP status code
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode >= 400
|
||||||
|
FACET httpResponseCode
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 4.3 403 errors (WriteAccessGuard rejections under load)
|
||||||
|
SELECT count(*) AS '403 Forbidden'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 403
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.4 429 errors (rate limiter – Throttler)
|
||||||
|
SELECT count(*) AS '429 Rate Limited'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 429
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.5 500 errors by endpoint
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 500
|
||||||
|
FACET name, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.6 JWT / auth failures
|
||||||
|
SELECT count(*)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%')
|
||||||
|
FACET errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 5: INFRASTRUCTURE (during test window) ───────────────────────
|
||||||
|
|
||||||
|
-- 5.1 CPU utilization
|
||||||
|
SELECT average(cpuPercent) AS 'CPU %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.2 Memory utilization
|
||||||
|
SELECT average(memoryUsedPercent) AS 'Memory %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.3 Network I/O
|
||||||
|
SELECT average(transmitBytesPerSecond) AS 'TX bytes/s',
|
||||||
|
average(receiveBytesPerSecond) AS 'RX bytes/s'
|
||||||
|
FROM NetworkSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 6: REDIS / BULLMQ ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 6.1 External call latency (Redis)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%redis%' OR host LIKE '%redis%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 6.2 All external service latency
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET host
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 7: BASELINE COMPARISON ───────────────────────────────────────
|
||||||
|
|
||||||
|
-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 this run'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00'
|
||||||
|
-- Run again with previous window dates to compare
|
||||||
|
|
||||||
|
-- 7.2 Regression check – endpoints that crossed p95 threshold
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND percentile(duration, 95) > 800 -- adjust to your staging threshold
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 8: TENANT-AWARE ANALYSIS ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 8.1 Performance by org (if orgId is in custom attributes)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET custom.orgId
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 8.2 Transactions without orgId (potential TenantMiddleware misses)
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND custom.orgId IS NULL
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/register%'
|
||||||
|
AND name NOT LIKE '%health%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
15
load-tests/user-pool.csv
Normal file
15
load-tests/user-pool.csv
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
email,password,orgId,role
|
||||||
|
treasurer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,treasurer
|
||||||
|
treasurer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,treasurer
|
||||||
|
treasurer03@loadtest.hoaledgeriq.com,LoadTest123!,org-003,treasurer
|
||||||
|
admin01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,admin
|
||||||
|
admin02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,admin
|
||||||
|
president01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,president
|
||||||
|
president02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,president
|
||||||
|
manager01@loadtest.hoaledgeriq.com,LoadTest123!,org-003,manager
|
||||||
|
manager02@loadtest.hoaledgeriq.com,LoadTest123!,org-004,manager
|
||||||
|
viewer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,viewer
|
||||||
|
viewer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,viewer
|
||||||
|
homeowner01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,homeowner
|
||||||
|
homeowner02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,homeowner
|
||||||
|
member01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,member_at_large
|
||||||
|
Reference in New Issue
Block a user