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 }