Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions backend/load-tests/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export const LOAD_TEST_DEFAULTS = {
duration: 30,
connections: 10,
pipelining: 1,
timeout: 10,
};

export const EXCHANGE_RATE_SCENARIOS = {
quoteSuccess: {
name: "Path Payment Quote — Success",
description: "Simulates concurrent requests for valid path payment quotes",
duration: 30,
connections: 10,
requests: [
{
method: "GET",
path: "/api/path-payment-quote/00000000-0000-0000-0000-000000000001",
query: {
source_asset: "XLM",
source_asset_issuer: "",
source_account: "",
},
},
],
},
quoteNotFound: {
name: "Path Payment Quote — 404",
description: "Simulates requests for non-existent payment IDs",
duration: 15,
connections: 5,
requests: [
{
method: "GET",
path: "/api/path-payment-quote/ffffffff-ffff-ffff-ffff-ffffffffffff",
query: {
source_asset: "XLM",
},
},
],
},
rateLimited: {
name: "Path Payment Quote — Rate Limited",
description: "Tests rate limiter behavior under high frequency requests",
duration: 5,
connections: 1,
requests: [
{
method: "GET",
path: "/api/path-payment-quote/00000000-0000-0000-0000-000000000001",
query: {
source_asset: "XLM",
},
},
],
},
};

export const THROUGHPUT_THRESHOLDS = {
minRequestsPerSecond: 5,
maxErrorRatePercent: 10,
maxP95LatencyMs: 5000,
};
211 changes: 211 additions & 0 deletions backend/load-tests/exchange-rate-load.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import request from "supertest";
import autocannon from "autocannon";

process.env.SUPABASE_URL ||= "https://example.supabase.co";
process.env.SUPABASE_SERVICE_ROLE_KEY ||= "test-service-role-key";
process.env.DATABASE_URL ||= "postgresql://postgres:postgres@127.0.0.1:5432/postgres";
process.env.STELLAR_NETWORK ||= "testnet";
process.env.PATH_PAYMENT_QUOTE_RATE_LIMIT_MAX ||= "200";

const { horizonMock } = vi.hoisted(() => ({
horizonMock: {
loadAccount: vi.fn().mockResolvedValue({ id: "test-account-id" }),
strictReceivePaths: vi.fn().mockReturnValue({
call: vi.fn().mockResolvedValue({
records: [
{
source_amount: "0.5000000",
source_asset_type: "credit_alphanum4",
source_asset_code: "USDC",
source_asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
destination_amount: "1.0000000",
path: [
{
asset_type: "credit_alphanum4",
asset_code: "USDC",
asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
},
],
},
],
}),
}),
},
}));

const { mockSupabase } = vi.hoisted(() => {
const sb = {
from: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({
data: {
id: "00000000-0000-0000-0000-000000000001",
amount: "1.0000000",
asset: "USDC",
asset_issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
recipient: "GA...",
status: "pending",
},
error: null,
}),
};
return { mockSupabase: sb };
});

vi.mock("../src/lib/supabase.js", () => ({
supabase: mockSupabase,
}));

vi.mock("@stellar/stellar-sdk", () => ({
Horizon: {
Server: vi.fn().mockImplementation(() => ({
loadAccount: horizonMock.loadAccount,
strictReceivePaths: horizonMock.strictReceivePaths,
})),
},
Keypair: { fromSecret: vi.fn().mockReturnValue({ publicKey: () => "test" }) },
Networks: { TESTNET: "TESTNET", PUBLIC: "PUBLIC" },
Asset: { native: vi.fn(), from: vi.fn() },
BASE_FEE: "100",
TransactionBuilder: vi.fn(),
Operation: { payment: vi.fn(), changeTrust: vi.fn() },
Memo: { text: vi.fn() },
TimeoutInfinite: 0,
}));

function formatResults(title, results) {
const lines = [
`\n=== ${title} ===`,
` Duration: ${results.duration}s`,
` Connections: ${results.connections}`,
` Requests: ${results.requests.total} (${results.requests.average} req/s)`,
` Throughput: ${(results.throughput.total / 1024 / 1024).toFixed(2)} MB`,
` Errors: ${results.errors}`,
` Timeouts: ${results.timeouts}`,
` Status codes: ${JSON.stringify(results.statusCodeStats)}`,
` P50: ${results.latency.p50}ms P90: ${results.latency.p90}ms P99: ${results.latency.p99}ms`,
` Non-2xx: ${results.non2xx}`,
];
return lines.join("\n");
}

function autocannonPromise(url, opts) {
return new Promise((resolve, reject) => {
const instance = autocannon({ url, ...opts }, (err, res) => {
if (err) reject(err);
else resolve(res);
});
autocannon.track(instance, { renderProgressBar: false });
});
}

describe("Multi-currency Exchange Rate Service — Load Tests", () => {
let appInstance;
let io;
let closePool;
let server;

beforeAll(async () => {
const [{ createApp }, { closePool: importedClosePool }] = await Promise.all([
import("../src/app.js"),
import("../src/lib/db.js"),
]);
closePool = importedClosePool;
const { app, io: ioInstance } = await createApp({
redisClient: {
ping: vi.fn().mockResolvedValue("PONG"),
on: vi.fn(),
sendCommand: vi.fn().mockResolvedValue("mocked_hash"),
isOpen: true,
},
});
appInstance = app;
io = ioInstance;
});

afterAll(async () => {
if (server) server.close();
if (io) {
try {
const httpServer = io.httpServer || io.server;
if (httpServer && typeof httpServer.close === "function") httpServer.close();
} catch {}
}
if (typeof closePool === "function") await closePool().catch(() => {});
});

it("verifies endpoint works via supertest", async () => {
const res = await request(appInstance)
.get("/api/path-payment-quote/00000000-0000-0000-0000-000000000001")
.query({ source_asset: "XLM" });
expect([200, 404, 409, 400, 500]).toContain(res.status);
console.log(`Supertest response: ${res.status} ${JSON.stringify(res.body).substring(0, 200)}`);
});

it("handles concurrent requests under load", async () => {
server = appInstance.listen(0);
const port = server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;

const results = await autocannonPromise(baseUrl, {
duration: 10,
connections: 5,
requests: [
{
method: "GET",
path: "/api/path-payment-quote/00000000-0000-0000-0000-000000000001?source_asset=XLM",
},
],
});

console.log(formatResults("Concurrent Load (10s, 5 connections)", results));
expect(results.timeouts).toBe(0);
expect(results.errors).toBe(0);
});

it("rejects excess requests with 429 beyond rate limit", async () => {
server = appInstance.listen(0);
const port = server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;

const results = await autocannonPromise(baseUrl, {
duration: 5,
connections: 1,
requests: Array.from({ length: 250 }, () => ({
method: "GET",
path: "/api/path-payment-quote/00000000-0000-0000-0000-000000000001?source_asset=XLM",
})),
});

console.log(formatResults("Rate Limit Test (5s, 1 connection, 250 requests)", results));

const has429 = Object.keys(results.statusCodeStats).some(
(code) => parseInt(code) === 429,
);
expect(has429).toBe(true);
expect(results.timeouts).toBe(0);
});

it("handles burst of connections without crash", async () => {
server = appInstance.listen(0);
const port = server.address().port;
const baseUrl = `http://127.0.0.1:${port}`;

const results = await autocannonPromise(baseUrl, {
duration: 10,
connections: 20,
requests: [
{
method: "GET",
path: "/api/path-payment-quote/00000000-0000-0000-0000-000000000001?source_asset=XLM",
},
],
});

console.log(formatResults("Burst Load (10s, 20 connections)", results));
expect(results.timeouts).toBe(0);
});
});
77 changes: 77 additions & 0 deletions backend/load-tests/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import autocannon from "autocannon";

export async function runAutocannon(url, opts = {}) {
return new Promise((resolve, reject) => {
const instance = autocannon(
{
url,
duration: 10,
connections: 5,
...opts,
},
(err, results) => {
if (err) return reject(err);
resolve(results);
},
);
autocannon.track(instance, { renderProgressBar: false });
});
}

export function formatResults(title, results) {
const lines = [
`\n=== ${title} ===`,
` Duration: ${results.duration}s`,
` Connections: ${results.connections}`,
` Pipelining: ${results.pipelining}`,
` Requests: ${results.requests.total} (${results.requests.average} req/s)`,
` Throughput: ${(results.throughput.total / 1024 / 1024).toFixed(2)} MB`,
` Errors: ${results.errors}`,
` Timeouts: ${results.timeouts}`,
` Status codes: ${JSON.stringify(results.statusCodeStats)}`,
` Latency (ms):`,
` min: ${results.latency.min}`,
` p1: ${results.latency.p1}`,
` p2.5: ${results.latency.p2_5}`,
` p50: ${results.latency.p50}`,
` p75: ${results.latency.p75}`,
` p90: ${results.latency.p90}`,
` p97.5: ${results.latency.p97_5}`,
` p99: ${results.latency.p99}`,
` max: ${results.latency.max}`,
` Non-2xx: ${results.non2xx}`,
` 1xx: ${results["1xx"]} 2xx: ${results["2xx"]} 3xx: ${results["3xx"]} 4xx: ${results["4xx"]} 5xx: ${results["5xx"]}`,
];
return lines.join("\n");
}

export function assertLoadTestThresholds(results, thresholds) {
const rps = results.requests.average;
const errorRate =
results.requests.total > 0
? ((results.non2xx / results.requests.total) * 100).toFixed(2)
: 0;
const p95 = results.latency.p95 || results.latency.p97_5;

const assertions = [];

if (rps < thresholds.minRequestsPerSecond) {
assertions.push(
`Throughput too low: ${rps.toFixed(1)} req/s (min: ${thresholds.minRequestsPerSecond})`,
);
}

if (errorRate > thresholds.maxErrorRatePercent) {
assertions.push(
`Error rate too high: ${errorRate}% (max: ${thresholds.maxErrorRatePercent}%)`,
);
}

if (p95 > thresholds.maxP95LatencyMs) {
assertions.push(
`P95 latency too high: ${p95}ms (max: ${thresholds.maxP95LatencyMs}ms)`,
);
}

return assertions;
}
Loading
Loading