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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user