Upload files to "load-tests"

load test files
This commit is contained in:
2026-03-19 16:11:32 -04:00
parent 66e2f87a96
commit 31f8274b8d
5 changed files with 642 additions and 0 deletions

View 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);
}