Files
Qris-Soundbox/scripts/load-test.mjs

414 lines
12 KiB
JavaScript

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