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>
378 lines
14 KiB
JavaScript
378 lines
14 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('crud_login_duration', true);
|
||
const createDuration = new Trend('crud_create_duration', true);
|
||
const readDuration = new Trend('crud_read_duration', true);
|
||
const updateDuration = new Trend('crud_update_duration', true);
|
||
const deleteDuration = new Trend('crud_delete_duration', true);
|
||
const listDuration = new Trend('crud_list_duration', true);
|
||
const crudErrors = new Rate('crud_error_rate');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Test user pool
|
||
// ---------------------------------------------------------------------------
|
||
const users = new SharedArray('users', function () {
|
||
const lines = open('../config/user-pool.csv').split('\n').slice(1);
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
export const options = {
|
||
scenarios: {
|
||
crud_flow: {
|
||
executor: 'ramping-vus',
|
||
startVUs: 0,
|
||
stages: [
|
||
{ duration: '1m', target: CONF.stages.rampUp },
|
||
{ duration: '5m', target: CONF.stages.steady },
|
||
{ duration: '1m', target: 0 },
|
||
],
|
||
gracefulStop: '30s',
|
||
},
|
||
},
|
||
thresholds: {
|
||
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||
crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||
crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||
crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||
crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||
crud_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||
},
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
function authHeaders(token) {
|
||
return {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
};
|
||
}
|
||
|
||
function jsonPost(url, body, token, tags) {
|
||
return http.post(url, JSON.stringify(body), {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
tags: tags || {},
|
||
});
|
||
}
|
||
|
||
function jsonPut(url, body, token, tags) {
|
||
return http.put(url, JSON.stringify(body), {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
tags: tags || {},
|
||
});
|
||
}
|
||
|
||
function login(user) {
|
||
const res = http.post(
|
||
`${BASE}/api/auth/login`,
|
||
JSON.stringify({ email: user.email, password: user.password }),
|
||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } },
|
||
);
|
||
loginDuration.add(res.timings.duration);
|
||
if (res.status !== 200 && res.status !== 201) return null;
|
||
try { return JSON.parse(res.body).accessToken; } catch { return null; }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// VU function – CRUD journey across core entities
|
||
// ---------------------------------------------------------------------------
|
||
export default function () {
|
||
const user = users[__VU % users.length];
|
||
|
||
// ── 1. Login ─────────────────────────────────────────────────────────
|
||
const accessToken = login(user);
|
||
if (!accessToken) {
|
||
crudErrors.add(1);
|
||
return;
|
||
}
|
||
crudErrors.add(0);
|
||
sleep(0.5);
|
||
|
||
// ── 2. Units CRUD ───────────────────────────────────────────────────
|
||
let unitId = null;
|
||
group('units_crud', () => {
|
||
// List
|
||
group('list_units', () => {
|
||
const res = http.get(`${BASE}/api/units`, authHeaders(accessToken));
|
||
listDuration.add(res.timings.duration);
|
||
check(res, { 'list units 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Create
|
||
group('create_unit', () => {
|
||
const payload = {
|
||
unitNumber: `LT-${__VU}-${Date.now()}`,
|
||
address: `${__VU} Load Test Lane`,
|
||
ownerName: `Load Tester ${__VU}`,
|
||
ownerEmail: `lt-${__VU}@loadtest.local`,
|
||
squareFeet: 1200,
|
||
};
|
||
const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' });
|
||
createDuration.add(res.timings.duration);
|
||
const ok = check(res, {
|
||
'create unit 200|201': (r) => r.status === 200 || r.status === 201,
|
||
});
|
||
if (ok) {
|
||
try { unitId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||
}
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Read
|
||
if (unitId) {
|
||
group('read_unit', () => {
|
||
const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken));
|
||
readDuration.add(res.timings.duration);
|
||
check(res, { 'read unit 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Update
|
||
group('update_unit', () => {
|
||
const res = jsonPut(`${BASE}/api/units/${unitId}`, {
|
||
ownerName: `Updated Tester ${__VU}`,
|
||
}, accessToken, { name: 'PUT /api/units/:id' });
|
||
updateDuration.add(res.timings.duration);
|
||
check(res, { 'update unit 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Delete
|
||
group('delete_unit', () => {
|
||
const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken));
|
||
deleteDuration.add(res.timings.duration);
|
||
check(res, { 'delete unit 200': (r) => r.status === 200 });
|
||
});
|
||
}
|
||
});
|
||
|
||
sleep(0.5);
|
||
|
||
// ── 3. Vendors CRUD ─────────────────────────────────────────────────
|
||
let vendorId = null;
|
||
group('vendors_crud', () => {
|
||
group('list_vendors', () => {
|
||
const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken));
|
||
listDuration.add(res.timings.duration);
|
||
check(res, { 'list vendors 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
group('create_vendor', () => {
|
||
const payload = {
|
||
name: `LT Vendor ${__VU}-${Date.now()}`,
|
||
email: `vendor-${__VU}@loadtest.local`,
|
||
phone: '555-0100',
|
||
category: 'maintenance',
|
||
};
|
||
const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' });
|
||
createDuration.add(res.timings.duration);
|
||
const ok = check(res, {
|
||
'create vendor 200|201': (r) => r.status === 200 || r.status === 201,
|
||
});
|
||
if (ok) {
|
||
try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||
}
|
||
});
|
||
sleep(0.3);
|
||
|
||
if (vendorId) {
|
||
group('read_vendor', () => {
|
||
const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken));
|
||
readDuration.add(res.timings.duration);
|
||
check(res, { 'read vendor 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
group('update_vendor', () => {
|
||
const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, {
|
||
name: `Updated Vendor ${__VU}`,
|
||
}, accessToken, { name: 'PUT /api/vendors/:id' });
|
||
updateDuration.add(res.timings.duration);
|
||
check(res, { 'update vendor 200': (r) => r.status === 200 });
|
||
});
|
||
}
|
||
});
|
||
|
||
sleep(0.5);
|
||
|
||
// ── 4. Journal Entries workflow ─────────────────────────────────────
|
||
let journalEntryId = null;
|
||
group('journal_entries', () => {
|
||
group('list_journal_entries', () => {
|
||
const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken));
|
||
listDuration.add(res.timings.duration);
|
||
check(res, { 'list JE 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Fetch accounts first so we can build a valid entry
|
||
let accounts = [];
|
||
group('fetch_accounts_for_je', () => {
|
||
const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken));
|
||
check(res, { 'list accounts 200': (r) => r.status === 200 });
|
||
try { accounts = JSON.parse(res.body); } catch { /* noop */ }
|
||
});
|
||
sleep(0.3);
|
||
|
||
if (Array.isArray(accounts) && accounts.length >= 2) {
|
||
group('create_journal_entry', () => {
|
||
const payload = {
|
||
date: new Date().toISOString().slice(0, 10),
|
||
memo: `Load test JE VU-${__VU}`,
|
||
type: 'standard',
|
||
lines: [
|
||
{ accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' },
|
||
{ accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' },
|
||
],
|
||
};
|
||
const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' });
|
||
createDuration.add(res.timings.duration);
|
||
const ok = check(res, {
|
||
'create JE 200|201': (r) => r.status === 200 || r.status === 201,
|
||
});
|
||
if (ok) {
|
||
try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||
}
|
||
});
|
||
sleep(0.3);
|
||
|
||
if (journalEntryId) {
|
||
group('read_journal_entry', () => {
|
||
const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken));
|
||
readDuration.add(res.timings.duration);
|
||
check(res, { 'read JE 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Post (finalize) the journal entry
|
||
group('post_journal_entry', () => {
|
||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken));
|
||
updateDuration.add(res.timings.duration);
|
||
check(res, { 'post JE 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
// Void the journal entry (cleanup)
|
||
group('void_journal_entry', () => {
|
||
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken));
|
||
deleteDuration.add(res.timings.duration);
|
||
check(res, { 'void JE 200': (r) => r.status === 200 });
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
sleep(0.5);
|
||
|
||
// ── 5. Payments CRUD ────────────────────────────────────────────────
|
||
let paymentId = null;
|
||
group('payments_crud', () => {
|
||
group('list_payments', () => {
|
||
const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken));
|
||
listDuration.add(res.timings.duration);
|
||
check(res, { 'list payments 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
group('create_payment', () => {
|
||
const payload = {
|
||
amount: 150.00,
|
||
date: new Date().toISOString().slice(0, 10),
|
||
method: 'check',
|
||
memo: `Load test payment VU-${__VU}`,
|
||
};
|
||
const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' });
|
||
createDuration.add(res.timings.duration);
|
||
const ok = check(res, {
|
||
'create payment 200|201': (r) => r.status === 200 || r.status === 201,
|
||
});
|
||
if (ok) {
|
||
try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||
}
|
||
});
|
||
sleep(0.3);
|
||
|
||
if (paymentId) {
|
||
group('read_payment', () => {
|
||
const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken));
|
||
readDuration.add(res.timings.duration);
|
||
check(res, { 'read payment 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
group('update_payment', () => {
|
||
const res = jsonPut(`${BASE}/api/payments/${paymentId}`, {
|
||
memo: `Updated payment VU-${__VU}`,
|
||
}, accessToken, { name: 'PUT /api/payments/:id' });
|
||
updateDuration.add(res.timings.duration);
|
||
check(res, { 'update payment 200': (r) => r.status === 200 });
|
||
});
|
||
sleep(0.3);
|
||
|
||
group('delete_payment', () => {
|
||
const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken));
|
||
deleteDuration.add(res.timings.duration);
|
||
check(res, { 'delete payment 200': (r) => r.status === 200 });
|
||
});
|
||
}
|
||
});
|
||
|
||
sleep(0.5);
|
||
|
||
// ── 6. Reports (read-heavy) ─────────────────────────────────────────
|
||
group('reports_read', () => {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const toDate = now.toISOString().slice(0, 10);
|
||
const fromDate = `${year}-01-01`;
|
||
|
||
const responses = http.batch([
|
||
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||
['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||
['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)],
|
||
]);
|
||
|
||
responses.forEach((res, i) => {
|
||
readDuration.add(res.timings.duration);
|
||
check(res, { [`report_${i} status 200`]: (r) => r.status === 200 });
|
||
});
|
||
});
|
||
|
||
sleep(1); // pacing
|
||
}
|