Upload files to "load-tests"
load test files
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user