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>
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
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
|
||
}
|