feat: add k6 load testing suite, NRQL query library, and CLAUDE.md
Add comprehensive load testing infrastructure: - k6 auth-dashboard flow (login → profile → dashboard KPIs → widgets → refresh → logout) - k6 CRUD flow (units, vendors, journal entries, payments, reports) - Environment configs with staging/production/local thresholds - Parameterized user pool CSV matching app roles - New Relic NRQL query library (25+ queries for perf analysis) - Empty baseline.json structure for all tested endpoints - CLAUDE.md documenting full stack, auth, route map, and conventions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
141
load-tests/analysis/baseline.json
Normal file
141
load-tests/analysis/baseline.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"_meta": {
|
||||
"capturedAt": null,
|
||||
"environment": "staging",
|
||||
"k6Version": null,
|
||||
"vus": null,
|
||||
"duration": null,
|
||||
"notes": "Empty baseline – populate after first stable load test run"
|
||||
},
|
||||
"auth": {
|
||||
"POST /api/auth/login": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"POST /api/auth/refresh": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"POST /api/auth/logout": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/auth/profile": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"GET /api/reports/dashboard": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/balance-sheet": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/income-statement": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/aging": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/health-scores/latest": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/cash-flow": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
},
|
||||
"GET /api/reports/cash-flow-forecast": {
|
||||
"p50": null,
|
||||
"p95": null,
|
||||
"p99": null,
|
||||
"errorRate": null,
|
||||
"rps": null
|
||||
}
|
||||
},
|
||||
"crud": {
|
||||
"units": {
|
||||
"GET /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"DELETE /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"vendors": {
|
||||
"GET /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"journalEntries": {
|
||||
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/journal-entries/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/journal-entries/:id/void": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"payments": {
|
||||
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"DELETE /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"accounts": {
|
||||
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"PUT /api/accounts/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"invoices": {
|
||||
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/invoices/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
}
|
||||
},
|
||||
"boardPlanning": {
|
||||
"GET /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"POST /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/board-planning/scenarios/:id/projection": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/board-planning/budget-plans": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
},
|
||||
"organizations": {
|
||||
"GET /api/organizations": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||
"GET /api/organizations/members": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||
}
|
||||
}
|
||||
270
load-tests/analysis/nrql-queries.sql
Normal file
270
load-tests/analysis/nrql-queries.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- =============================================================================
|
||||
-- HOA Financial Platform (HOALedgerIQ) – New Relic NRQL Query Library
|
||||
-- App Name: HOALedgerIQ_App (see NEW_RELIC_APP_NAME env var)
|
||||
-- =============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. OVERVIEW & THROUGHPUT
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Overall throughput (requests/min) over past hour
|
||||
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Throughput by HTTP method
|
||||
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Apdex score over time
|
||||
SELECT apdex(duration, t: 0.5)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. AUTHENTICATION ENDPOINTS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Login latency (p50, p95, p99)
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Login Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/login'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Login error rate
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Login Error %'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/login'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Token refresh latency
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Refresh Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/auth/refresh'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Auth endpoints overview
|
||||
SELECT count(*), average(duration), percentile(duration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/auth/%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. DASHBOARD & REPORTS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Dashboard KPI latency
|
||||
SELECT percentile(duration, 50, 95, 99) AS 'Dashboard Latency (s)'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri = '/api/reports/dashboard'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- All report endpoints performance
|
||||
SELECT count(*), average(duration), percentile(duration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/reports/%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Slowest report queries (> 2s)
|
||||
SELECT count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/reports/%'
|
||||
AND duration > 2
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. CRUD OPERATIONS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Units endpoint latency by method
|
||||
SELECT percentile(duration, 50, 95) AS 'Units Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/units%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Vendors endpoint latency by method
|
||||
SELECT percentile(duration, 50, 95) AS 'Vendors Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/vendors%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Journal entries performance
|
||||
SELECT percentile(duration, 50, 95) AS 'JE Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/journal-entries%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Payments performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Payments Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/payments%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Accounts performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Accounts Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/accounts%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Invoices performance
|
||||
SELECT percentile(duration, 50, 95) AS 'Invoices Latency'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/invoices%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. MULTI-TENANT / ORG OPERATIONS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Organizations endpoint performance
|
||||
SELECT percentile(duration, 50, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/organizations%'
|
||||
FACET request.method
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Board planning (complex module) latency
|
||||
SELECT count(*), percentile(duration, 50, 95, 99)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND request.uri LIKE '/api/board-planning%'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. ERROR ANALYSIS
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Error rate by endpoint (top 20 offenders)
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Error %',
|
||||
count(*) AS 'Total'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 20;
|
||||
|
||||
-- 5xx errors specifically
|
||||
SELECT count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND httpResponseCode >= 500
|
||||
FACET request.uri, httpResponseCode
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- Error rate over time
|
||||
SELECT percentage(count(*), WHERE httpResponseCode >= 500) AS 'Server Error %',
|
||||
percentage(count(*), WHERE httpResponseCode >= 400 AND httpResponseCode < 500) AS 'Client Error %'
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. DATABASE & EXTERNAL SERVICES
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Database call duration (TypeORM / Postgres)
|
||||
SELECT average(databaseDuration), percentile(databaseDuration, 95)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Slowest DB transactions
|
||||
SELECT average(databaseDuration), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND databaseDuration > 1
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 20;
|
||||
|
||||
-- External service calls (Stripe, Resend, NVIDIA AI)
|
||||
SELECT average(externalDuration), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
AND externalDuration > 0
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. LOAD TEST COMPARISON
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Compare metrics between two time windows (baseline vs test)
|
||||
-- Adjust SINCE/UNTIL for your test windows
|
||||
SELECT percentile(duration, 50, 95, 99), count(*), percentage(count(*), WHERE httpResponseCode >= 500)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
SINCE 2 hours ago
|
||||
UNTIL 1 hour ago
|
||||
COMPARE WITH 1 hour ago;
|
||||
|
||||
-- Per-endpoint comparison during load test window
|
||||
SELECT average(duration), percentile(duration, 95), count(*)
|
||||
FROM Transaction
|
||||
WHERE appName = 'HOALedgerIQ_App'
|
||||
FACET request.uri
|
||||
SINCE 1 hour ago
|
||||
LIMIT 50;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. INFRASTRUCTURE (if NR Infrastructure agent is installed)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- CPU utilization
|
||||
SELECT average(cpuPercent)
|
||||
FROM SystemSample
|
||||
WHERE hostname LIKE '%hoaledgeriq%'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Memory utilization
|
||||
SELECT average(memoryUsedPercent)
|
||||
FROM SystemSample
|
||||
WHERE hostname LIKE '%hoaledgeriq%'
|
||||
SINCE 1 hour ago
|
||||
TIMESERIES AUTO;
|
||||
|
||||
-- Connection pool saturation (custom metric – requires NR custom events)
|
||||
-- SELECT average(custom.db.pool.active), average(custom.db.pool.idle)
|
||||
-- FROM Metric
|
||||
-- WHERE appName = 'HOALedgerIQ_App'
|
||||
-- SINCE 1 hour ago
|
||||
-- TIMESERIES AUTO;
|
||||
53
load-tests/config/environments.json
Normal file
53
load-tests/config/environments.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"staging": {
|
||||
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||
"stages": {
|
||||
"rampUp": 10,
|
||||
"steady": 25,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 2000,
|
||||
"login_p95": 1500,
|
||||
"dashboard_p95": 3000,
|
||||
"crud_write_p95": 2000,
|
||||
"crud_read_p95": 1500,
|
||||
"error_rate": 0.05
|
||||
},
|
||||
"notes": "Staging environment – smaller infra, relaxed thresholds"
|
||||
},
|
||||
"production": {
|
||||
"baseUrl": "https://app.hoaledgeriq.com",
|
||||
"stages": {
|
||||
"rampUp": 20,
|
||||
"steady": 50,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 1000,
|
||||
"login_p95": 800,
|
||||
"dashboard_p95": 1500,
|
||||
"crud_write_p95": 1000,
|
||||
"crud_read_p95": 800,
|
||||
"error_rate": 0.01
|
||||
},
|
||||
"notes": "Production thresholds – strict SLA targets"
|
||||
},
|
||||
"local": {
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"stages": {
|
||||
"rampUp": 5,
|
||||
"steady": 10,
|
||||
"rampDown": 0
|
||||
},
|
||||
"thresholds": {
|
||||
"http_req_duration_p95": 3000,
|
||||
"login_p95": 2000,
|
||||
"dashboard_p95": 5000,
|
||||
"crud_write_p95": 3000,
|
||||
"crud_read_p95": 2000,
|
||||
"error_rate": 0.10
|
||||
},
|
||||
"notes": "Local dev – generous thresholds for single-machine testing"
|
||||
}
|
||||
}
|
||||
11
load-tests/config/user-pool.csv
Normal file
11
load-tests/config/user-pool.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
email,password,orgId,role
|
||||
loadtest-treasurer-01@hoaledgeriq.test,LoadTest!Pass01,org-uuid-placeholder-1,treasurer
|
||||
loadtest-treasurer-02@hoaledgeriq.test,LoadTest!Pass02,org-uuid-placeholder-1,treasurer
|
||||
loadtest-admin-01@hoaledgeriq.test,LoadTest!Pass03,org-uuid-placeholder-1,admin
|
||||
loadtest-admin-02@hoaledgeriq.test,LoadTest!Pass04,org-uuid-placeholder-2,admin
|
||||
loadtest-president-01@hoaledgeriq.test,LoadTest!Pass05,org-uuid-placeholder-2,president
|
||||
loadtest-manager-01@hoaledgeriq.test,LoadTest!Pass06,org-uuid-placeholder-2,manager
|
||||
loadtest-member-01@hoaledgeriq.test,LoadTest!Pass07,org-uuid-placeholder-1,member_at_large
|
||||
loadtest-viewer-01@hoaledgeriq.test,LoadTest!Pass08,org-uuid-placeholder-1,viewer
|
||||
loadtest-homeowner-01@hoaledgeriq.test,LoadTest!Pass09,org-uuid-placeholder-2,homeowner
|
||||
loadtest-homeowner-02@hoaledgeriq.test,LoadTest!Pass10,org-uuid-placeholder-2,homeowner
|
||||
|
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginDuration = new Trend('login_duration', true);
|
||||
const refreshDuration = new Trend('refresh_duration', true);
|
||||
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||
const profileDuration = new Trend('profile_duration', true);
|
||||
const loginFailures = new Counter('login_failures');
|
||||
const authErrors = new Rate('auth_error_rate');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test user pool – parameterized from CSV
|
||||
// ---------------------------------------------------------------------------
|
||||
const users = new SharedArray('users', function () {
|
||||
const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header
|
||||
return lines
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((line) => {
|
||||
const [email, password, orgId, role] = line.split(',');
|
||||
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment config
|
||||
// ---------------------------------------------------------------------------
|
||||
const ENV = JSON.parse(open('../config/environments.json'));
|
||||
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||
const BASE = CONF.baseUrl;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// k6 options – ramp-up / steady / ramp-down
|
||||
// ---------------------------------------------------------------------------
|
||||
export const options = {
|
||||
scenarios: {
|
||||
auth_dashboard: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '1m', target: CONF.stages.rampUp }, // ramp-up
|
||||
{ duration: '5m', target: CONF.stages.steady }, // steady state
|
||||
{ duration: '1m', target: 0 }, // ramp-down
|
||||
],
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||
login_duration: [`p(95)<${CONF.thresholds.login_p95}`],
|
||||
dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`],
|
||||
auth_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function authHeaders(accessToken) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonPost(url, body, params = {}) {
|
||||
return http.post(url, JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) },
|
||||
tags: params.tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default VU function – login → dashboard journey
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function () {
|
||||
const user = users[__VU % users.length];
|
||||
|
||||
let accessToken = null;
|
||||
|
||||
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||
group('01_login', () => {
|
||||
const res = jsonPost(`${BASE}/api/auth/login`, {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
}, { tags: { name: 'POST /api/auth/login' } });
|
||||
|
||||
loginDuration.add(res.timings.duration);
|
||||
|
||||
const ok = check(res, {
|
||||
'login status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
'login returns accessToken': (r) => {
|
||||
try { return !!JSON.parse(r.body).accessToken; } catch { return false; }
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
loginFailures.add(1);
|
||||
authErrors.add(1);
|
||||
return; // abort journey – cannot continue without token
|
||||
}
|
||||
authErrors.add(0);
|
||||
|
||||
const body = JSON.parse(res.body);
|
||||
accessToken = body.accessToken;
|
||||
});
|
||||
|
||||
if (!accessToken) return; // guard
|
||||
|
||||
sleep(0.5); // think-time between login and dashboard load
|
||||
|
||||
// ── 2. Fetch profile ────────────────────────────────────────────────
|
||||
group('02_profile', () => {
|
||||
const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken));
|
||||
profileDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'profile status 200': (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── 3. Dashboard KPIs ───────────────────────────────────────────────
|
||||
group('03_dashboard', () => {
|
||||
const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken));
|
||||
dashboardDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'dashboard status 200': (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.3);
|
||||
|
||||
// ── 4. Parallel dashboard widgets (batch) ───────────────────────────
|
||||
group('04_dashboard_widgets', () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const fromDate = `${year}-01-01`;
|
||||
const toDate = now.toISOString().slice(0, 10);
|
||||
|
||||
const responses = http.batch([
|
||||
['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)],
|
||||
]);
|
||||
|
||||
responses.forEach((res, i) => {
|
||||
check(res, {
|
||||
[`widget_${i} status 200`]: (r) => r.status === 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 5. Refresh token ───────────────────────────────────────────────
|
||||
group('05_refresh_token', () => {
|
||||
const res = http.post(`${BASE}/api/auth/refresh`, null, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'POST /api/auth/refresh' },
|
||||
});
|
||||
refreshDuration.add(res.timings.duration);
|
||||
check(res, {
|
||||
'refresh status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 6. Logout ──────────────────────────────────────────────────────
|
||||
group('06_logout', () => {
|
||||
const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken));
|
||||
check(res, {
|
||||
'logout status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1); // pacing between iterations
|
||||
}
|
||||
377
load-tests/scenarios/crud-flow.js
Normal file
377
load-tests/scenarios/crud-flow.js
Normal file
@@ -0,0 +1,377 @@
|
||||
import http from 'k6/http';
|
||||
import { check, group, sleep } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginDuration = new Trend('crud_login_duration', true);
|
||||
const createDuration = new Trend('crud_create_duration', true);
|
||||
const readDuration = new Trend('crud_read_duration', true);
|
||||
const updateDuration = new Trend('crud_update_duration', true);
|
||||
const deleteDuration = new Trend('crud_delete_duration', true);
|
||||
const listDuration = new Trend('crud_list_duration', true);
|
||||
const crudErrors = new Rate('crud_error_rate');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test user pool
|
||||
// ---------------------------------------------------------------------------
|
||||
const users = new SharedArray('users', function () {
|
||||
const lines = open('../config/user-pool.csv').split('\n').slice(1);
|
||||
return lines
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((line) => {
|
||||
const [email, password, orgId, role] = line.split(',');
|
||||
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment config
|
||||
// ---------------------------------------------------------------------------
|
||||
const ENV = JSON.parse(open('../config/environments.json'));
|
||||
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||
const BASE = CONF.baseUrl;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// k6 options
|
||||
// ---------------------------------------------------------------------------
|
||||
export const options = {
|
||||
scenarios: {
|
||||
crud_flow: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: '1m', target: CONF.stages.rampUp },
|
||||
{ duration: '5m', target: CONF.stages.steady },
|
||||
{ duration: '1m', target: 0 },
|
||||
],
|
||||
gracefulStop: '30s',
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||
crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||
crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||
crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||
crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||
crud_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function authHeaders(token) {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonPost(url, body, token, tags) {
|
||||
return http.post(url, JSON.stringify(body), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
function jsonPut(url, body, token, tags) {
|
||||
return http.put(url, JSON.stringify(body), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
tags: tags || {},
|
||||
});
|
||||
}
|
||||
|
||||
function login(user) {
|
||||
const res = http.post(
|
||||
`${BASE}/api/auth/login`,
|
||||
JSON.stringify({ email: user.email, password: user.password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } },
|
||||
);
|
||||
loginDuration.add(res.timings.duration);
|
||||
if (res.status !== 200 && res.status !== 201) return null;
|
||||
try { return JSON.parse(res.body).accessToken; } catch { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VU function – CRUD journey across core entities
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function () {
|
||||
const user = users[__VU % users.length];
|
||||
|
||||
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||
const accessToken = login(user);
|
||||
if (!accessToken) {
|
||||
crudErrors.add(1);
|
||||
return;
|
||||
}
|
||||
crudErrors.add(0);
|
||||
sleep(0.5);
|
||||
|
||||
// ── 2. Units CRUD ───────────────────────────────────────────────────
|
||||
let unitId = null;
|
||||
group('units_crud', () => {
|
||||
// List
|
||||
group('list_units', () => {
|
||||
const res = http.get(`${BASE}/api/units`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list units 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Create
|
||||
group('create_unit', () => {
|
||||
const payload = {
|
||||
unitNumber: `LT-${__VU}-${Date.now()}`,
|
||||
address: `${__VU} Load Test Lane`,
|
||||
ownerName: `Load Tester ${__VU}`,
|
||||
ownerEmail: `lt-${__VU}@loadtest.local`,
|
||||
squareFeet: 1200,
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create unit 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { unitId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Read
|
||||
if (unitId) {
|
||||
group('read_unit', () => {
|
||||
const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Update
|
||||
group('update_unit', () => {
|
||||
const res = jsonPut(`${BASE}/api/units/${unitId}`, {
|
||||
ownerName: `Updated Tester ${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/units/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Delete
|
||||
group('delete_unit', () => {
|
||||
const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'delete unit 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 3. Vendors CRUD ─────────────────────────────────────────────────
|
||||
let vendorId = null;
|
||||
group('vendors_crud', () => {
|
||||
group('list_vendors', () => {
|
||||
const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list vendors 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('create_vendor', () => {
|
||||
const payload = {
|
||||
name: `LT Vendor ${__VU}-${Date.now()}`,
|
||||
email: `vendor-${__VU}@loadtest.local`,
|
||||
phone: '555-0100',
|
||||
category: 'maintenance',
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create vendor 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (vendorId) {
|
||||
group('read_vendor', () => {
|
||||
const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read vendor 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('update_vendor', () => {
|
||||
const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, {
|
||||
name: `Updated Vendor ${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/vendors/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update vendor 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 4. Journal Entries workflow ─────────────────────────────────────
|
||||
let journalEntryId = null;
|
||||
group('journal_entries', () => {
|
||||
group('list_journal_entries', () => {
|
||||
const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Fetch accounts first so we can build a valid entry
|
||||
let accounts = [];
|
||||
group('fetch_accounts_for_je', () => {
|
||||
const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken));
|
||||
check(res, { 'list accounts 200': (r) => r.status === 200 });
|
||||
try { accounts = JSON.parse(res.body); } catch { /* noop */ }
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (Array.isArray(accounts) && accounts.length >= 2) {
|
||||
group('create_journal_entry', () => {
|
||||
const payload = {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
memo: `Load test JE VU-${__VU}`,
|
||||
type: 'standard',
|
||||
lines: [
|
||||
{ accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' },
|
||||
{ accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' },
|
||||
],
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create JE 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (journalEntryId) {
|
||||
group('read_journal_entry', () => {
|
||||
const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Post (finalize) the journal entry
|
||||
group('post_journal_entry', () => {
|
||||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken));
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'post JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
// Void the journal entry (cleanup)
|
||||
group('void_journal_entry', () => {
|
||||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'void JE 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 5. Payments CRUD ────────────────────────────────────────────────
|
||||
let paymentId = null;
|
||||
group('payments_crud', () => {
|
||||
group('list_payments', () => {
|
||||
const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken));
|
||||
listDuration.add(res.timings.duration);
|
||||
check(res, { 'list payments 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('create_payment', () => {
|
||||
const payload = {
|
||||
amount: 150.00,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
method: 'check',
|
||||
memo: `Load test payment VU-${__VU}`,
|
||||
};
|
||||
const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' });
|
||||
createDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'create payment 200|201': (r) => r.status === 200 || r.status === 201,
|
||||
});
|
||||
if (ok) {
|
||||
try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||
}
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
if (paymentId) {
|
||||
group('read_payment', () => {
|
||||
const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken));
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { 'read payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('update_payment', () => {
|
||||
const res = jsonPut(`${BASE}/api/payments/${paymentId}`, {
|
||||
memo: `Updated payment VU-${__VU}`,
|
||||
}, accessToken, { name: 'PUT /api/payments/:id' });
|
||||
updateDuration.add(res.timings.duration);
|
||||
check(res, { 'update payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
sleep(0.3);
|
||||
|
||||
group('delete_payment', () => {
|
||||
const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken));
|
||||
deleteDuration.add(res.timings.duration);
|
||||
check(res, { 'delete payment 200': (r) => r.status === 200 });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(0.5);
|
||||
|
||||
// ── 6. Reports (read-heavy) ─────────────────────────────────────────
|
||||
group('reports_read', () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const toDate = now.toISOString().slice(0, 10);
|
||||
const fromDate = `${year}-01-01`;
|
||||
|
||||
const responses = http.batch([
|
||||
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||
['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)],
|
||||
]);
|
||||
|
||||
responses.forEach((res, i) => {
|
||||
readDuration.add(res.timings.duration);
|
||||
check(res, { [`report_${i} status 200`]: (r) => r.status === 200 });
|
||||
});
|
||||
});
|
||||
|
||||
sleep(1); // pacing
|
||||
}
|
||||
Reference in New Issue
Block a user