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 }