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);
|
||||||
|
}
|
||||||
45
load-tests/baseline.json
Normal file
45
load-tests/baseline.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
|
||||||
|
"last_updated": "YYYY-MM-DD",
|
||||||
|
"last_run_cycle": 0,
|
||||||
|
"units": "milliseconds"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"journal_entries": {
|
||||||
|
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
259
load-tests/crud-flow.js
Normal file
259
load-tests/crud-flow.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Core CRUD Workflow Load Test
|
||||||
|
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
|
||||||
|
* Record Payment → View Accounts → Budget vs Actual → Logout
|
||||||
|
*
|
||||||
|
* This scenario exercises write-heavy paths gated by WriteAccessGuard
|
||||||
|
* and the TenantMiddleware schema-switch. Run this alongside
|
||||||
|
* auth-dashboard-flow.js to simulate a realistic mixed workload.
|
||||||
|
*
|
||||||
|
* Role used: treasurer (has full write access, most common power user)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate } from 'k6/metrics';
|
||||||
|
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const journalEntryDuration = new Trend('journal_entry_duration', true);
|
||||||
|
const invoiceDuration = new Trend('invoice_duration', true);
|
||||||
|
const paymentDuration = new Trend('payment_duration', true);
|
||||||
|
const accountsReadDuration = new Trend('accounts_read_duration', true);
|
||||||
|
const budgetDuration = new Trend('budget_vs_actual_duration',true);
|
||||||
|
const crudErrorRate = new Rate('crud_error_rate');
|
||||||
|
const writeGuardErrorRate = new Rate('write_guard_error_rate');
|
||||||
|
|
||||||
|
// ── User pool (treasurer + admin roles only for write access) ────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.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() };
|
||||||
|
})
|
||||||
|
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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: {
|
||||||
|
crud_workflow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 10 }, // warm up (writes need more care)
|
||||||
|
{ duration: '5m', target: 50 }, // ramp to target
|
||||||
|
{ duration: '5m', target: 50 }, // sustained
|
||||||
|
{ duration: '3m', target: 100 }, // peak
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
|
||||||
|
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function jsonHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYear() {
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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' } }
|
||||||
|
);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
|
||||||
|
let debitAccountId = null;
|
||||||
|
let creditAccountId = null;
|
||||||
|
|
||||||
|
group('accounts:list', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'accounts 200': r => r.status === 200,
|
||||||
|
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
const accounts = res.json();
|
||||||
|
// Pick first two distinct accounts for the journal entry
|
||||||
|
debitAccountId = accounts[0]?.id;
|
||||||
|
creditAccountId = accounts[1]?.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 3. Create journal entry (draft) ────────────────────────────────────
|
||||||
|
let journalEntryId = null;
|
||||||
|
|
||||||
|
group('journal:create', () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
description: `Load test entry ${uuidv4().slice(0, 8)}`,
|
||||||
|
lines: [
|
||||||
|
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
|
||||||
|
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
// Watch for WriteAccessGuard rejections (403)
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'journal create 201': r => r.status === 201,
|
||||||
|
'journal has id': r => r.json('id') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (ok) journalEntryId = res.json('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Post the journal entry ────────────────────────────────────────────
|
||||||
|
if (journalEntryId) {
|
||||||
|
group('journal:post', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
|
||||||
|
);
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, { 'journal post 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
sleep(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Generate invoice preview ─────────────────────────────────────────
|
||||||
|
let invoicePreviewOk = false;
|
||||||
|
group('invoice:preview', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/invoices/generate-preview`,
|
||||||
|
JSON.stringify({ period: currentYear() }),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
|
||||||
|
);
|
||||||
|
invoiceDuration.add(res.timings.duration);
|
||||||
|
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!invoicePreviewOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reviews invoice preview
|
||||||
|
|
||||||
|
// ── 6. Create a payment record ───────────────────────────────────────────
|
||||||
|
group('payment:create', () => {
|
||||||
|
const payload = {
|
||||||
|
amount: 150.00,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
method: 'check',
|
||||||
|
description: `Load test payment ${uuidv4().slice(0, 8)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/payments`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
|
||||||
|
);
|
||||||
|
paymentDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
|
||||||
|
group('budget:vs-actual', () => {
|
||||||
|
const year = currentYear();
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/budgets/${year}/vs-actual`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
|
||||||
|
);
|
||||||
|
budgetDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 8. Trial balance read ────────────────────────────────────────────────
|
||||||
|
group('accounts:trial-balance', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts/trial-balance`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'trial balance 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 9. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
117
load-tests/cycle-template.md
Normal file
117
load-tests/cycle-template.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# HOALedgerIQ – Load Test Improvement Report
|
||||||
|
**Cycle:** 001
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Test window:** HH:MM – HH:MM UTC
|
||||||
|
**Environments:** Staging (`staging.hoaledgeriq.com`)
|
||||||
|
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
|
||||||
|
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
|
||||||
|
**New Relic app:** `HOALedgerIQ_App`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
|
||||||
|
|
||||||
|
**Threshold breaches this cycle:**
|
||||||
|
|
||||||
|
| Metric | Target | Actual | Status |
|
||||||
|
|--------|--------|--------|--------|
|
||||||
|
| login p95 | < 300ms | — | 🔴 / 🟢 |
|
||||||
|
| dashboard p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 |
|
||||||
|
| error rate | < 1% | — | 🔴 / 🟢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 🔴 P0 – Fix Before Next Deploy
|
||||||
|
|
||||||
|
#### Finding 001 – [Short title]
|
||||||
|
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
|
||||||
|
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
|
||||||
|
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_
|
||||||
|
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
|
||||||
|
- **Recommended fix:**
|
||||||
|
```typescript
|
||||||
|
// BEFORE – N+1: one query per component
|
||||||
|
for (const component of components) {
|
||||||
|
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER – batch load with WHERE IN
|
||||||
|
const actuals = await this.actualsRepo.findBy({
|
||||||
|
componentId: In(components.map(c => c.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- **Expected improvement:** ~70% latency reduction on this endpoint
|
||||||
|
- **Effort:** Low (1–2 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 P1 – Fix Within This Sprint
|
||||||
|
|
||||||
|
#### Finding 002 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **New Relic evidence:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **File:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Expected improvement:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
#### Finding 003 – [Short title]
|
||||||
|
- _(same structure)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 P2 – Backlog
|
||||||
|
|
||||||
|
#### Finding 004 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Net — Re-Test Criteria
|
||||||
|
|
||||||
|
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
|
||||||
|
|
||||||
|
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|
||||||
|
|----------|-------------|------------|-------------|
|
||||||
|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
|
||||||
|
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
|
||||||
|
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
|
||||||
|
|
||||||
|
> **Claude Code update command (run after confirming fixes):**
|
||||||
|
> ```bash
|
||||||
|
> claude "Update load-tests/analysis/baseline.json with the p95 values from
|
||||||
|
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
|
||||||
|
> load-tests/config/environments.json staging block to match. Do not loosen
|
||||||
|
> any threshold that already passes."
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Delta
|
||||||
|
|
||||||
|
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|
||||||
|
|----------|--------------|--------------|---|
|
||||||
|
| _(populated after first run)_ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Observations
|
||||||
|
|
||||||
|
- _Any anomalies, flaky tests, or infrastructure events during the run_
|
||||||
|
- _Redis / BullMQ queue depth observations_
|
||||||
|
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
|
||||||
|
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._
|
||||||
38
load-tests/environments.json
Normal file
38
load-tests/environments.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"local": {
|
||||||
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 500,
|
||||||
|
"refresh_p95": 300,
|
||||||
|
"read_p95": 1000,
|
||||||
|
"write_p95": 1500,
|
||||||
|
"dashboard_p95": 1500,
|
||||||
|
"global_p99": 3000,
|
||||||
|
"error_rate": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 300,
|
||||||
|
"refresh_p95": 200,
|
||||||
|
"read_p95": 800,
|
||||||
|
"write_p95": 1200,
|
||||||
|
"dashboard_p95": 1000,
|
||||||
|
"global_p99": 2000,
|
||||||
|
"error_rate": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"baseUrl": "https://app.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 200,
|
||||||
|
"refresh_p95": 150,
|
||||||
|
"read_p95": 500,
|
||||||
|
"write_p95": 800,
|
||||||
|
"dashboard_p95": 700,
|
||||||
|
"global_p99": 1500,
|
||||||
|
"error_rate": 0.005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user