Upload files to "load-tests"
load test files
This commit is contained in:
183
load-tests/auth-dashboard-flow.js
Normal file
183
load-tests/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* HOALedgerIQ – Auth + Dashboard Load Test
|
||||
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
|
||||
*
|
||||
* Covers the highest-frequency production flow: a treasurer or admin
|
||||
* opening the app, loading the dashboard, and reviewing financial reports.
|
||||
*/
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, sleep, group } from 'k6';
|
||||
import { SharedArray } from 'k6/data';
|
||||
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||
|
||||
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||
const loginDuration = new Trend('login_duration', true);
|
||||
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||
const refreshDuration = new Trend('refresh_duration', true);
|
||||
const authErrorRate = new Rate('auth_error_rate');
|
||||
const dashboardErrorRate = new Rate('dashboard_error_rate');
|
||||
const tokenRefreshCount = new Counter('token_refresh_count');
|
||||
|
||||
// ── User pool ────────────────────────────────────────────────────────────────
|
||||
const users = new SharedArray('users', function () {
|
||||
return open('../config/user-pool.csv')
|
||||
.split('\n')
|
||||
.slice(1) // skip header row
|
||||
.filter(line => line.trim())
|
||||
.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 = __ENV.TARGET_ENV || 'staging';
|
||||
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||
const BASE_URL = envConfig.baseUrl;
|
||||
|
||||
// ── Test options ─────────────────────────────────────────────────────────────
|
||||
export const options = {
|
||||
scenarios: {
|
||||
auth_dashboard: {
|
||||
executor: 'ramping-vus',
|
||||
stages: [
|
||||
{ duration: '2m', target: 20 }, // warm up
|
||||
{ duration: '5m', target: 100 }, // ramp to target load
|
||||
{ duration: '5m', target: 100 }, // sustained load
|
||||
{ duration: '3m', target: 200 }, // peak spike
|
||||
{ duration: '2m', target: 0 }, // ramp down
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
// Latency targets per environment (overridden by environments.json)
|
||||
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
|
||||
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
|
||||
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function authHeaders(token) {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||
export default function () {
|
||||
const user = users[__VU % users.length];
|
||||
let accessToken = null;
|
||||
|
||||
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||
group('auth:login', () => {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/login`,
|
||||
JSON.stringify({ email: user.email, password: user.password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||
);
|
||||
|
||||
loginDuration.add(res.timings.duration);
|
||||
const ok = check(res, {
|
||||
'login 200': r => r.status === 200,
|
||||
'has access_token': r => r.json('access_token') !== undefined,
|
||||
'has orgId in body': r => r.json('user.orgId') !== undefined,
|
||||
});
|
||||
authErrorRate.add(!ok);
|
||||
if (!ok) { sleep(1); return; }
|
||||
|
||||
accessToken = res.json('access_token');
|
||||
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
|
||||
});
|
||||
|
||||
if (!accessToken) return;
|
||||
sleep(1.5); // think time – user lands on dashboard
|
||||
|
||||
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
|
||||
group('dashboard:load', () => {
|
||||
const requests = {
|
||||
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
|
||||
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
|
||||
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
|
||||
profile: ['GET', `${BASE_URL}/api/auth/profile`],
|
||||
accounts: ['GET', `${BASE_URL}/api/accounts`],
|
||||
};
|
||||
|
||||
const responses = http.batch(
|
||||
Object.entries(requests).map(([name, [method, url]]) => ({
|
||||
method, url,
|
||||
params: { headers: authHeaders(accessToken), tags: { name } },
|
||||
}))
|
||||
);
|
||||
|
||||
let allOk = true;
|
||||
responses.forEach((res, i) => {
|
||||
const name = Object.keys(requests)[i];
|
||||
dashboardDuration.add(res.timings.duration, { endpoint: name });
|
||||
const ok = check(res, {
|
||||
[`${name} 200`]: r => r.status === 200,
|
||||
[`${name} has body`]: r => r.body && r.body.length > 0,
|
||||
});
|
||||
if (!ok) allOk = false;
|
||||
});
|
||||
dashboardErrorRate.add(!allOk);
|
||||
});
|
||||
|
||||
sleep(2); // user reads the dashboard
|
||||
|
||||
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
|
||||
// In the load test we trigger it early to validate the refresh path under load
|
||||
group('auth:refresh', () => {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/refresh`,
|
||||
null,
|
||||
{
|
||||
headers: authHeaders(accessToken),
|
||||
tags: { name: 'refresh' },
|
||||
// k6 sends the httpOnly cookie from the jar automatically
|
||||
}
|
||||
);
|
||||
|
||||
refreshDuration.add(res.timings.duration);
|
||||
tokenRefreshCount.add(1);
|
||||
const ok = check(res, {
|
||||
'refresh 200': r => r.status === 200,
|
||||
'new access_token': r => r.json('access_token') !== undefined,
|
||||
});
|
||||
authErrorRate.add(!ok);
|
||||
if (ok) accessToken = res.json('access_token');
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
|
||||
// ── 4. Drill into one report (cash-flow forecast – typically slowest) ────
|
||||
group('dashboard:drill', () => {
|
||||
const res = http.get(
|
||||
`${BASE_URL}/api/reports/cash-flow-forecast`,
|
||||
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
|
||||
);
|
||||
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
|
||||
dashboardErrorRate.add(res.status !== 200);
|
||||
check(res, { 'forecast 200': r => r.status === 200 });
|
||||
});
|
||||
|
||||
sleep(2);
|
||||
|
||||
// ── 5. Logout ────────────────────────────────────────────────────────────
|
||||
group('auth:logout', () => {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/logout`,
|
||||
null,
|
||||
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
|
||||
);
|
||||
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
|
||||
});
|
||||
|
||||
sleep(1);
|
||||
}
|
||||
Reference in New Issue
Block a user