/** * 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); }