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

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

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

View File

@@ -0,0 +1,189 @@
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
import { Counter, Rate, Trend } from 'k6/metrics';
// ---------------------------------------------------------------------------
// Custom metrics
// ---------------------------------------------------------------------------
const loginDuration = new Trend('login_duration', true);
const refreshDuration = new Trend('refresh_duration', true);
const dashboardDuration = new Trend('dashboard_duration', true);
const profileDuration = new Trend('profile_duration', true);
const loginFailures = new Counter('login_failures');
const authErrors = new Rate('auth_error_rate');
// ---------------------------------------------------------------------------
// Test user pool parameterized from CSV
// ---------------------------------------------------------------------------
const users = new SharedArray('users', function () {
const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header
return lines
.filter((l) => l.trim().length > 0)
.map((line) => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
});
});
// ---------------------------------------------------------------------------
// Environment config
// ---------------------------------------------------------------------------
const ENV = JSON.parse(open('../config/environments.json'));
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
const BASE = CONF.baseUrl;
// ---------------------------------------------------------------------------
// k6 options ramp-up / steady / ramp-down
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
auth_dashboard: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '1m', target: CONF.stages.rampUp }, // ramp-up
{ duration: '5m', target: CONF.stages.steady }, // steady state
{ duration: '1m', target: 0 }, // ramp-down
],
gracefulStop: '30s',
},
},
thresholds: {
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
login_duration: [`p(95)<${CONF.thresholds.login_p95}`],
dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`],
auth_error_rate: [`rate<${CONF.thresholds.error_rate}`],
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function authHeaders(accessToken) {
return {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
};
}
function jsonPost(url, body, params = {}) {
return http.post(url, JSON.stringify(body), {
headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) },
tags: params.tags || {},
});
}
// ---------------------------------------------------------------------------
// Default VU function login → dashboard journey
// ---------------------------------------------------------------------------
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ─────────────────────────────────────────────────────────
group('01_login', () => {
const res = jsonPost(`${BASE}/api/auth/login`, {
email: user.email,
password: user.password,
}, { tags: { name: 'POST /api/auth/login' } });
loginDuration.add(res.timings.duration);
const ok = check(res, {
'login status 200|201': (r) => r.status === 200 || r.status === 201,
'login returns accessToken': (r) => {
try { return !!JSON.parse(r.body).accessToken; } catch { return false; }
},
});
if (!ok) {
loginFailures.add(1);
authErrors.add(1);
return; // abort journey cannot continue without token
}
authErrors.add(0);
const body = JSON.parse(res.body);
accessToken = body.accessToken;
});
if (!accessToken) return; // guard
sleep(0.5); // think-time between login and dashboard load
// ── 2. Fetch profile ────────────────────────────────────────────────
group('02_profile', () => {
const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken));
profileDuration.add(res.timings.duration);
check(res, {
'profile status 200': (r) => r.status === 200,
});
});
sleep(0.3);
// ── 3. Dashboard KPIs ───────────────────────────────────────────────
group('03_dashboard', () => {
const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken));
dashboardDuration.add(res.timings.duration);
check(res, {
'dashboard status 200': (r) => r.status === 200,
});
});
sleep(0.3);
// ── 4. Parallel dashboard widgets (batch) ───────────────────────────
group('04_dashboard_widgets', () => {
const now = new Date();
const year = now.getFullYear();
const fromDate = `${year}-01-01`;
const toDate = now.toISOString().slice(0, 10);
const responses = http.batch([
['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)],
['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)],
]);
responses.forEach((res, i) => {
check(res, {
[`widget_${i} status 200`]: (r) => r.status === 200,
});
});
});
sleep(0.5);
// ── 5. Refresh token ───────────────────────────────────────────────
group('05_refresh_token', () => {
const res = http.post(`${BASE}/api/auth/refresh`, null, {
headers: { 'Content-Type': 'application/json' },
tags: { name: 'POST /api/auth/refresh' },
});
refreshDuration.add(res.timings.duration);
check(res, {
'refresh status 200|201': (r) => r.status === 200 || r.status === 201,
});
});
sleep(0.5);
// ── 6. Logout ──────────────────────────────────────────────────────
group('06_logout', () => {
const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken));
check(res, {
'logout status 200|201': (r) => r.status === 200 || r.status === 201,
});
});
sleep(1); // pacing between iterations
}