#!/usr/bin/env node import { createHmac } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { Pool } from "pg"; import "dotenv/config"; const PORT = process.env.PORT || "3120"; const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token"; const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token"; const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret"; const RUN_ID = process.env.LOAD_RUN_ID || `load-${Date.now()}`; const CALLBACKS = Number(process.env.LOAD_CALLBACKS || 30); const HEARTBEATS = Number(process.env.LOAD_HEARTBEATS || 60); const DYNAMIC_QR = Number(process.env.LOAD_DYNAMIC_QR || 30); const READS = Number(process.env.LOAD_READS || 30); const EXPORTS = Number(process.env.LOAD_EXPORTS || 0); const CONCURRENCY = Number(process.env.LOAD_CONCURRENCY || 10); const created = { merchantIds: [], partnerReferences: [] }; const metrics = new Map(); function percentile(values, p) { if (!values.length) { return 0; } const sorted = [...values].sort((a, b) => a - b); const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); return sorted[index]; } function record(label, durationMs, ok) { const entry = metrics.get(label) || { durations: [], ok: 0, error: 0 }; entry.durations.push(durationMs); if (ok) { entry.ok += 1; } else { entry.error += 1; } metrics.set(label, entry); } async function req(path, options = {}) { const startedAt = performance.now(); const label = options.label || `${options.method || "GET"} ${path}`; try { const response = await fetch(`${BASE}${path}`, { method: options.method || "GET", headers: { "Content-Type": "application/json", ...(options.headers || {}) }, body: Object.prototype.hasOwnProperty.call(options, "body") ? JSON.stringify(options.body) : undefined }); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } const ok = response.ok; record(label, performance.now() - startedAt, ok); if (!ok) { throw new Error(`${label} failed status=${response.status} body=${typeof body === "string" ? body.slice(0, 120) : JSON.stringify(body).slice(0, 120)}`); } return body?.data !== undefined ? body.data : body; } catch (error) { record(label, performance.now() - startedAt, false); throw error; } } function reqAdmin(path, options = {}) { return req(path, { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${ADMIN_TOKEN}` } }); } function reqDevice(path, options = {}) { return req(path, { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${DEVICE_TOKEN}` } }); } async function runPool(items, concurrency, worker) { const queue = [...items]; const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { while (queue.length) { const item = queue.shift(); await worker(item); } }); await Promise.all(workers); } async function waitForHealth() { for (let i = 0; i < 100; i += 1) { try { await req("/health", { label: "health_check" }); return; } catch { await new Promise((resolve) => setTimeout(resolve, 200)); } } throw new Error("health check timeout"); } async function createBundle() { const merchant = await reqAdmin("/admin/merchants", { method: "POST", body: { legal_name: `Load Test Merchant ${RUN_ID}`, brand_name: `LOAD-${RUN_ID}`, settlement_account_reference: `bank:${RUN_ID}`, settlement_account_type: "merchant_bank_account", payout_mode: "merchant_direct" }, label: "setup_create_merchant" }); created.merchantIds.push(merchant.id); const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, { method: "POST", body: { name: `Load Outlet ${RUN_ID}` }, label: "setup_create_outlet" }); const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, { method: "POST", body: { terminal_code: `LOAD-TERM-${RUN_ID}`, qr_mode: "static" }, label: "setup_create_terminal" }); const device = await reqAdmin("/admin/devices", { method: "POST", body: { device_code: `LOAD-DEV-${RUN_ID}`, vendor: "load", model: "static", communication_mode: "mqtt", status: "active" }, label: "setup_create_device" }); await reqAdmin(`/admin/devices/${device.id}/bind`, { method: "POST", body: { merchant_id: merchant.id, outlet_id: outlet.id, terminal_id: terminal.id }, label: "setup_bind_device" }); const dynamicOutlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, { method: "POST", body: { name: `Load Dynamic Outlet ${RUN_ID}` }, label: "setup_create_dynamic_outlet" }); const dynamicTerminal = await reqAdmin(`/admin/outlets/${dynamicOutlet.id}/terminals`, { method: "POST", body: { terminal_code: `LOAD-DYN-${RUN_ID}`, qr_mode: "dynamic_api" }, label: "setup_create_dynamic_terminal" }); const dynamicDevice = await reqAdmin("/admin/devices", { method: "POST", body: { device_code: `LOAD-DYN-DEV-${RUN_ID}`, vendor: "load", model: "dynamic-api", communication_mode: "api", capability_profile_json: { dynamic_qr: { api_direct: true, mqtt: false }, flows: ["dynamic_qr:api_direct", "static_payment_notification"] }, status: "active" }, label: "setup_create_dynamic_device" }); await reqAdmin(`/admin/devices/${dynamicDevice.id}/bind`, { method: "POST", body: { merchant_id: merchant.id, outlet_id: dynamicOutlet.id, terminal_id: dynamicTerminal.id }, label: "setup_bind_dynamic_device" }); return { merchant, outlet, terminal, device, dynamicOutlet, dynamicTerminal, dynamicDevice }; } async function createTransaction(bundle, index) { const partnerReference = `LOAD-PR-${RUN_ID}-${index}`; created.partnerReferences.push(partnerReference); return reqAdmin("/admin/transactions", { method: "POST", body: { partner_reference: partnerReference, merchant_id: bundle.merchant.id, outlet_id: bundle.outlet.id, terminal_id: bundle.terminal.id, device_id: bundle.device.id, amount: 10000 + index, currency: "IDR", qr_mode: "static", initiation_mode: "static", status: "initiated" }, label: "transaction_create" }); } async function callbackPaid(index) { const callback = { partner_reference: `LOAD-PR-${RUN_ID}-${index}`, partner_txn_id: `LOAD-PTX-${RUN_ID}-${index}`, amount: 10000 + index, currency: "IDR", payment_status: "paid", status: "paid", paid_at: new Date().toISOString() }; const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex"); return req("/integrations/qris/callback", { method: "POST", headers: { "X-Partner-Signature": signature }, body: { ...callback, signature }, label: "qris_callback_paid" }); } async function heartbeat(bundle, index) { return reqDevice("/device/heartbeat", { method: "POST", body: { device_id: bundle.device.id, timestamp: new Date().toISOString(), firmware_version: "load-1.0.0", network_strength: 70 + (index % 20), battery_level: 60 + (index % 30), state: "load-test" }, label: "device_heartbeat" }); } async function dynamicQr(bundle, index) { const requestId = `LOAD-DYN-${RUN_ID}-${index}`; return reqDevice("/device/transactions/dynamic-qr", { method: "POST", headers: { "Idempotency-Key": requestId }, body: { device_id: bundle.dynamicDevice.id, terminal_id: bundle.dynamicTerminal.id, amount: 15000 + index, currency: "IDR", request_id: requestId, expires_in_seconds: 300 }, label: "dynamic_qr_create" }); } async function exportAdjustments(index) { const job = await reqAdmin("/admin/exports/settlement-adjustments", { method: "POST", body: { limit: 500 }, label: "export_adjustments_create" }); let current = job; for (let attempt = 0; attempt < 30 && !["completed", "failed"].includes(current.status); attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 500)); current = await reqAdmin(`/admin/exports/${job.id}`, { label: "export_adjustments_poll" }); } if (current.status !== "completed") { throw new Error(`export job ${index} ended with status ${current.status}`); } await reqAdmin(`/admin/exports/${job.id}/download`, { label: "export_adjustments_download" }); } async function cleanup() { const pool = new Pool( process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { host: process.env.PGHOST || "127.0.0.1", port: Number(process.env.PGPORT || 5432), user: process.env.PGUSER || "postgres", password: process.env.PGPASSWORD || "postgres", database: process.env.PGDATABASE || "qris_soundbox_platform" } ); try { const deletedTransactions = await pool.query( "DELETE FROM transactions WHERE partner_reference = ANY($1::text[]) OR partner_reference LIKE $2 RETURNING id", [created.partnerReferences, `LOAD-%-${RUN_ID}-%`] ); const deletedMerchants = await pool.query( "DELETE FROM merchants WHERE id = ANY($1::text[]) OR legal_name = $2 RETURNING id", [created.merchantIds, `Load Test Merchant ${RUN_ID}`] ); return { transactions_deleted: deletedTransactions.rowCount, merchants_deleted: deletedMerchants.rowCount }; } finally { await pool.end(); } } function printSummary(startedAt, cleanupResult) { const totalDurationMs = performance.now() - startedAt; const rows = [...metrics.entries()].map(([label, entry]) => ({ label, ok: entry.ok, error: entry.error, count: entry.durations.length, p50_ms: Number(percentile(entry.durations, 50).toFixed(2)), p95_ms: Number(percentile(entry.durations, 95).toFixed(2)), max_ms: Number(Math.max(...entry.durations).toFixed(2)) })); const totalOk = rows.reduce((sum, row) => sum + row.ok, 0); const totalError = rows.reduce((sum, row) => sum + row.error, 0); const summary = { run_id: RUN_ID, base_url: BASE, config: { callbacks: CALLBACKS, heartbeats: HEARTBEATS, dynamic_qr: DYNAMIC_QR, reads: READS, exports: EXPORTS, concurrency: CONCURRENCY }, totals: { ok: totalOk, error: totalError, duration_ms: Number(totalDurationMs.toFixed(2)), approx_throughput_rps: Number((totalOk / (totalDurationMs / 1000)).toFixed(2)) }, metrics: rows, cleanup: cleanupResult }; const serialized = JSON.stringify(summary, null, 2); console.log(serialized); if (process.env.LOAD_REPORT_FILE) { const reportFile = path.resolve(process.cwd(), process.env.LOAD_REPORT_FILE); fs.mkdirSync(path.dirname(reportFile), { recursive: true }); fs.writeFileSync(reportFile, `${serialized}\n`, "utf8"); } } async function main() { const startedAt = performance.now(); let cleanupResult = null; try { await waitForHealth(); const bundle = await createBundle(); const callbackIndexes = Array.from({ length: CALLBACKS }, (_item, index) => index + 1); await runPool(callbackIndexes, CONCURRENCY, (index) => createTransaction(bundle, index)); await runPool(callbackIndexes, CONCURRENCY, callbackPaid); await runPool(Array.from({ length: HEARTBEATS }, (_item, index) => index + 1), CONCURRENCY, (index) => heartbeat(bundle, index) ); await runPool(Array.from({ length: DYNAMIC_QR }, (_item, index) => index + 1), CONCURRENCY, (index) => dynamicQr(bundle, index) ); await runPool(Array.from({ length: READS }, (_item, index) => index + 1), CONCURRENCY, async () => { await reqAdmin("/admin/observability/summary", { label: "observability_summary" }); }); await runPool(Array.from({ length: EXPORTS }, (_item, index) => index + 1), Math.min(CONCURRENCY, 5), exportAdjustments); cleanupResult = await cleanup(); printSummary(startedAt, cleanupResult); } catch (error) { cleanupResult = await cleanup().catch((cleanupError) => ({ error: cleanupError.message })); printSummary(startedAt, cleanupResult); throw error; } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });