414 lines
12 KiB
JavaScript
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);
|
|
});
|