diff --git a/src/configValidator.ts b/src/configValidator.ts index 46ddb31..db3950f 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -9,6 +9,8 @@ export interface ConfigValidationErrorType { severity: "error" | "warning"; } +export type ConfigValidationErrorType = ConfigValidationIssue; + export interface ConfigValidation { valid: boolean; errors: ConfigValidationErrorType[]; diff --git a/src/dedup.ts b/src/dedup.ts index 485edcd..4080a61 100644 --- a/src/dedup.ts +++ b/src/dedup.ts @@ -1,3 +1,41 @@ +import crypto from "crypto"; + +export interface IdempotencyKeyParams { + invoiceId: string; + payer: string; + amount: bigint; + nonce?: string; +} + +const knownKeys = new Set(); + +/** + * Generate a canonical idempotency key for payment deduplication. + * + * Computes a deterministic SHA-256 hex digest over `{invoiceId}:{payer}:{amount}` + * (with an optional `:{nonce}` suffix when supplied). + */ +export function generateIdempotencyKey(params: IdempotencyKeyParams): string { + const base = `${params.invoiceId}:${params.payer}:${params.amount.toString()}`; + const payload = params.nonce !== undefined ? `${base}:${params.nonce}` : base; + return crypto.createHash("sha256").update(payload).digest("hex"); +} + +/** Check if an idempotency key has already been registered in memory. */ +export function isKnownKey(key: string): boolean { + return knownKeys.has(key); +} + +/** Register an idempotency key in memory. */ +export function registerKey(key: string): void { + knownKeys.add(key); +} + +/** Clear all registered idempotency keys (useful for test teardown). */ +export function clearKeys(): void { + knownKeys.clear(); +} + export class Deduplicator { private _inflight = new Map>(); private _hits = 0; diff --git a/src/index.ts b/src/index.ts index 4970e15..323e343 100644 --- a/src/index.ts +++ b/src/index.ts @@ -434,7 +434,14 @@ export { buildRevealTransactionFromStorage, } from "./confidential.js"; -export { Deduplicator } from "./dedup.js"; +export { + Deduplicator, + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, +} from "./dedup.js"; +export type { IdempotencyKeyParams } from "./dedup.js"; export { TxQueue } from "./queue.js"; diff --git a/src/loadTesting.ts b/src/loadTesting.ts new file mode 100644 index 0000000..616e727 --- /dev/null +++ b/src/loadTesting.ts @@ -0,0 +1,336 @@ +/** + * Comprehensive SDK performance and load testing suite. + * + * Provides concurrency control, pacing/rate limiting, latency percentile analysis, + * event emission, and comparative benchmarking for StellarSplit SDK operations. + */ + +export interface LoadTestOptions { + /** Optional descriptive name for the load test scenario. */ + name?: string; + /** Number of concurrent virtual workers executing tasks (default: 1). */ + concurrency?: number; + /** Total number of iterations to execute across all workers (optional if durationMs is provided). */ + iterations?: number; + /** Maximum duration in milliseconds to run the load test (optional if iterations is provided). */ + durationMs?: number; + /** Ramp-up period in milliseconds to gradually scale concurrency (default: 0). */ + rampUpMs?: number; + /** Target requests/operations per second to pace execution (optional). */ + targetRps?: number; + /** Per-iteration timeout in milliseconds (optional). */ + timeoutMs?: number; +} + +export interface LoadTestContext { + /** 0-based worker index. */ + workerId: number; + /** 0-based overall iteration index. */ + iteration: number; + /** Timestamp when load test started. */ + startedAt: number; +} + +export interface LoadTestIterationResult { + iteration: number; + workerId: number; + durationMs: number; + success: boolean; + value?: T; + error?: Error; +} + +export interface LatencyStats { + min: number; + max: number; + mean: number; + median: number; + p90: number; + p95: number; + p99: number; + stdDev: number; +} + +export interface LoadTestReport { + name: string; + totalIterations: number; + successCount: number; + failureCount: number; + errorRate: number; + durationMs: number; + throughputRps: number; + concurrency: number; + latency: LatencyStats; + errorBreakdown: Record; +} + +export type LoadTestEventMap = { + start: (options: LoadTestOptions) => void; + progress: (completed: number, total: number, currentRps: number) => void; + iterationStart: (context: LoadTestContext) => void; + iterationEnd: (result: LoadTestIterationResult) => void; + error: (error: Error, context: LoadTestContext) => void; + complete: (report: LoadTestReport) => void; +}; + +export type LoadTestEventType = keyof LoadTestEventMap; + +/** + * Calculates statistical metrics and percentiles from an array of numeric durations. + */ +export function computeLatencyStats(durationsMs: number[]): LatencyStats { + if (durationsMs.length === 0) { + return { + min: 0, + max: 0, + mean: 0, + median: 0, + p90: 0, + p95: 0, + p99: 0, + stdDev: 0, + }; + } + + const sorted = [...durationsMs].sort((a, b) => a - b); + const n = sorted.length; + const min = sorted[0]!; + const max = sorted[n - 1]!; + const sum = sorted.reduce((acc, val) => acc + val, 0); + const mean = sum / n; + + const getPercentile = (p: number): number => { + const idx = Math.min(n - 1, Math.max(0, Math.ceil((p / 100) * n) - 1)); + return sorted[idx]!; + }; + + const variance = + sorted.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / n; + const stdDev = Math.sqrt(variance); + + return { + min: Number(min.toFixed(2)), + max: Number(max.toFixed(2)), + mean: Number(mean.toFixed(2)), + median: Number(getPercentile(50).toFixed(2)), + p90: Number(getPercentile(90).toFixed(2)), + p95: Number(getPercentile(95).toFixed(2)), + p99: Number(getPercentile(99).toFixed(2)), + stdDev: Number(stdDev.toFixed(2)), + }; +} + +/** + * High-performance load testing engine with event dispatch and percentile reporting. + */ +export class LoadTester { + private readonly listeners: { + [K in LoadTestEventType]?: Array; + } = {}; + + /** Register an event listener. */ + on(event: K, listener: LoadTestEventMap[K]): this { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event]!.push(listener); + return this; + } + + /** Remove an event listener. */ + off(event: K, listener: LoadTestEventMap[K]): this { + const arr = this.listeners[event]; + if (arr) { + this.listeners[event] = arr.filter((fn) => fn !== listener) as any; + } + return this; + } + + /** Emit an event to all registered listeners. */ + private emit( + event: K, + ...args: Parameters + ): void { + const arr = this.listeners[event]; + if (arr) { + for (const listener of arr) { + try { + (listener as any)(...args); + } catch { + // Prevent listener errors from aborting the load test + } + } + } + } + + /** + * Run a load testing scenario. + * + * @param options - Load test execution options + * @param task - Asynchronous workload to execute per iteration + */ + async run( + options: LoadTestOptions, + task: (context: LoadTestContext) => Promise + ): Promise { + const name = options.name ?? "SDK Load Test"; + const concurrency = Math.max(1, options.concurrency ?? 1); + const targetIterations = options.iterations ?? (options.durationMs ? Infinity : 100); + const durationMs = options.durationMs ?? Infinity; + const rampUpMs = options.rampUpMs ?? 0; + const timeoutMs = options.timeoutMs; + const minIntervalMs = options.targetRps ? 1000 / options.targetRps : 0; + + this.emit("start", options); + + const startTime = performance.now(); + const durationsMs: number[] = []; + let successCount = 0; + let failureCount = 0; + const errorBreakdown: Record = {}; + + let currentIteration = 0; + let stopRequested = false; + + const executeIteration = async ( + workerId: number, + iterIndex: number + ): Promise => { + const context: LoadTestContext = { + workerId, + iteration: iterIndex, + startedAt: startTime, + }; + + this.emit("iterationStart", context); + const iterStart = performance.now(); + + let success = false; + let value: T | undefined; + let error: Error | undefined; + + try { + if (timeoutMs) { + let timer: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Iteration timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + value = await Promise.race([task(context), timeoutPromise]); + clearTimeout(timer!); + } else { + value = await task(context); + } + success = true; + successCount++; + } catch (err) { + error = err instanceof Error ? err : new Error(String(err)); + failureCount++; + const errMsg = error.message; + errorBreakdown[errMsg] = (errorBreakdown[errMsg] ?? 0) + 1; + this.emit("error", error, context); + } + + const iterDuration = performance.now() - iterStart; + durationsMs.push(iterDuration); + + this.emit("iterationEnd", { + iteration: iterIndex, + workerId, + durationMs: iterDuration, + success, + value, + error, + }); + }; + + // Worker loop + const runWorker = async (workerId: number): Promise => { + // Handle ramp up delay for this worker + if (rampUpMs > 0 && concurrency > 1) { + const workerRampDelay = (workerId / concurrency) * rampUpMs; + await new Promise((r) => setTimeout(r, workerRampDelay)); + } + + while (!stopRequested) { + const iterIndex = currentIteration++; + if (iterIndex >= targetIterations) { + break; + } + + const elapsed = performance.now() - startTime; + if (elapsed >= durationMs) { + stopRequested = true; + break; + } + + const paceStart = performance.now(); + await executeIteration(workerId, iterIndex); + + if (minIntervalMs > 0) { + const paceElapsed = performance.now() - paceStart; + const sleepMs = minIntervalMs - paceElapsed; + if (sleepMs > 0) { + await new Promise((r) => setTimeout(r, sleepMs)); + } + } + } + }; + + // Run workers concurrently + const workers = Array.from({ length: concurrency }, (_, id) => runWorker(id)); + await Promise.all(workers); + + const totalDurationMs = performance.now() - startTime; + const totalIterations = successCount + failureCount; + const errorRate = totalIterations === 0 ? 0 : failureCount / totalIterations; + const throughputRps = + totalDurationMs === 0 ? 0 : Number(((totalIterations / totalDurationMs) * 1000).toFixed(2)); + + const latency = computeLatencyStats(durationsMs); + + const report: LoadTestReport = { + name, + totalIterations, + successCount, + failureCount, + errorRate: Number(errorRate.toFixed(4)), + durationMs: Number(totalDurationMs.toFixed(2)), + throughputRps, + concurrency, + latency, + errorBreakdown, + }; + + this.emit("complete", report); + return report; + } +} + +/** + * Format a human-readable load test report table. + */ +export function formatLoadTestReport(report: LoadTestReport): string { + return [ + `════════════════════════════════════════════════════════════════`, + ` ${report.name}`, + `════════════════════════════════════════════════════════════════`, + `Total Operations: ${report.totalIterations} (${report.successCount} passed, ${report.failureCount} failed)`, + `Error Rate: ${(report.errorRate * 100).toFixed(2)}%`, + `Duration: ${report.durationMs}ms`, + `Throughput: ${report.throughputRps} ops/sec`, + `Concurrency: ${report.concurrency}`, + `Latency (ms):`, + ` min: ${report.latency.min} ms`, + ` median: ${report.latency.median} ms`, + ` mean: ${report.latency.mean} ms`, + ` p90: ${report.latency.p90} ms`, + ` p95: ${report.latency.p95} ms`, + ` p99: ${report.latency.p99} ms`, + ` max: ${report.latency.max} ms`, + ` stdDev: ${report.latency.stdDev} ms`, + `════════════════════════════════════════════════════════════════`, + ].join("\n"); +} diff --git a/src/requestCache.ts b/src/requestCache.ts new file mode 100644 index 0000000..ac8c794 --- /dev/null +++ b/src/requestCache.ts @@ -0,0 +1,268 @@ +/** + * Intelligent in-memory request caching layer with TTL support, + * LRU eviction, and metrics for StellarSplit SDK. + */ + +import type { Invoice } from "./types.js"; + +export interface CacheStats { + hits: number; + misses: number; + totalRequests: number; + hitRate: number; + size: number; + maxEntries: number; + evictions: number; + invalidations: number; +} + +export interface RequestCacheOptions { + /** Default time-to-live in milliseconds for cached entries (default: 60_000). */ + defaultTtlMs?: number; + /** Maximum number of entries to retain before evicting least-recently used items (default: 1_000). */ + maxEntries?: number; +} + +interface CacheItem { + value: T; + expiresAt: number; +} + +/** + * Generate a deterministic, collision-resistant cache key. + * + * Parameters are sorted recursively so that parameter order does not alter + * the generated key. + */ +export function generateCacheKey( + namespace: string, + params?: Record | string | number | boolean | null +): string { + if (params === undefined || params === null) { + return `${namespace}:_nil_`; + } + + if (typeof params !== "object") { + return `${namespace}:${String(params)}`; + } + + const serialize = (obj: unknown): string => { + if (obj === null || typeof obj !== "object") { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + return `[${obj.map(serialize).join(",")}]`; + } + const keys = Object.keys(obj as Record).sort(); + const parts = keys.map( + (k) => `${JSON.stringify(k)}:${serialize((obj as Record)[k])}` + ); + return `{${parts.join(",")}}`; + }; + + return `${namespace}:${serialize(params)}`; +} + +/** + * Intelligent in-memory request cache supporting TTL expiration, + * LRU eviction, namespace invalidation, and metrics collection. + */ +export class RequestCache { + private readonly store = new Map>(); + private readonly defaultTtlMs: number; + private readonly maxEntries: number; + + private hits = 0; + private misses = 0; + private evictions = 0; + private invalidations = 0; + + constructor(options: RequestCacheOptions = {}) { + this.defaultTtlMs = options.defaultTtlMs ?? 60_000; + this.maxEntries = options.maxEntries ?? 1_000; + } + + /** + * Retrieve a value from the cache. + * Updates LRU ordering if present and unexpired. + */ + get(key: string): T | undefined { + const item = this.store.get(key); + if (!item) { + this.misses++; + return undefined; + } + + if (Date.now() > item.expiresAt) { + this.store.delete(key); + this.misses++; + return undefined; + } + + // Refresh LRU order by re-inserting + this.store.delete(key); + this.store.set(key, item); + this.hits++; + return item.value; + } + + /** + * Store a value in the cache with an optional custom TTL. + */ + set(key: string, value: T, ttlMs?: number): void { + const ttl = ttlMs ?? this.defaultTtlMs; + const expiresAt = Date.now() + ttl; + + if (this.store.has(key)) { + this.store.delete(key); + } else if (this.store.size >= this.maxEntries) { + // Evict least-recently used entry (first entry in Map iteration order) + const oldestKey = this.store.keys().next().value; + if (oldestKey !== undefined) { + this.store.delete(oldestKey); + this.evictions++; + } + } + + this.store.set(key, { value, expiresAt }); + } + + /** Check if a valid, unexpired entry exists without updating LRU or hit/miss metrics. */ + has(key: string): boolean { + const item = this.store.get(key); + if (!item) return false; + if (Date.now() > item.expiresAt) { + this.store.delete(key); + return false; + } + return true; + } + + /** + * Fetch a value from cache, or invoke the fetch function on cache miss + * and store the result. + */ + async getOrFetch( + key: string, + fetchFn: () => Promise, + ttlMs?: number + ): Promise { + const cached = this.get(key); + if (cached !== undefined) { + return cached as R; + } + + const fresh = await fetchFn(); + this.set(key, fresh, ttlMs); + return fresh; + } + + /** Remove a specific key. */ + invalidate(key: string): boolean { + const deleted = this.store.delete(key); + if (deleted) { + this.invalidations++; + } + return deleted; + } + + /** Invalidate all entries starting with a namespace prefix. */ + invalidateNamespace(namespace: string): number { + const prefix = `${namespace}:`; + let count = 0; + for (const key of Array.from(this.store.keys())) { + if (key.startsWith(prefix)) { + this.store.delete(key); + count++; + } + } + this.invalidations += count; + return count; + } + + /** Invalidate entries matching a predicate function. */ + invalidateByPredicate(predicate: (key: string, value: T) => boolean): number { + let count = 0; + for (const [key, item] of Array.from(this.store.entries())) { + if (predicate(key, item.value)) { + this.store.delete(key); + count++; + } + } + this.invalidations += count; + return count; + } + + /** Clear all entries in the cache. */ + clear(): void { + const size = this.store.size; + this.store.clear(); + this.invalidations += size; + } + + /** Get live cache performance and size metrics. */ + getStats(): CacheStats { + const totalRequests = this.hits + this.misses; + const hitRate = totalRequests === 0 ? 0 : this.hits / totalRequests; + return { + hits: this.hits, + misses: this.misses, + totalRequests, + hitRate, + size: this.store.size, + maxEntries: this.maxEntries, + evictions: this.evictions, + invalidations: this.invalidations, + }; + } + + /** Reset hit, miss, eviction, and invalidation counters. */ + resetStats(): void { + this.hits = 0; + this.misses = 0; + this.evictions = 0; + this.invalidations = 0; + } + + // ── Specialized domain helpers ────────────────────────────────────────── + + /** Cache an invoice query result. */ + cacheInvoice(invoiceId: string, invoice: Invoice, ttlMs?: number): void { + this.set(generateCacheKey("invoice", { id: invoiceId }), invoice as unknown as T, ttlMs); + } + + /** Retrieve a cached invoice if present. */ + getCachedInvoice(invoiceId: string): Invoice | undefined { + return this.get(generateCacheKey("invoice", { id: invoiceId })) as unknown as Invoice | undefined; + } + + /** Invalidate a specific cached invoice. */ + invalidateInvoice(invoiceId: string): boolean { + return this.invalidate(generateCacheKey("invoice", { id: invoiceId })); + } + + /** Cache user account information. */ + cacheAccount(address: string, accountInfo: unknown, ttlMs?: number): void { + this.set(generateCacheKey("account", { address }), accountInfo as T, ttlMs); + } + + /** Retrieve cached user account information. */ + getCachedAccount(address: string): unknown | undefined { + return this.get(generateCacheKey("account", { address })); + } + + /** Invalidate cached user account information. */ + invalidateAccount(address: string): boolean { + return this.invalidate(generateCacheKey("account", { address })); + } + + /** Cache invoice statistics or aggregations. */ + cacheStats(statsKey: string, statsData: unknown, ttlMs?: number): void { + this.set(generateCacheKey("stats", { key: statsKey }), statsData as T, ttlMs); + } + + /** Retrieve cached statistics. */ + getCachedStats(statsKey: string): unknown | undefined { + return this.get(generateCacheKey("stats", { key: statsKey })); + } +} diff --git a/src/search.ts b/src/search.ts index 8d84608..8a0ab0a 100644 --- a/src/search.ts +++ b/src/search.ts @@ -20,6 +20,41 @@ export interface SearchResult { status: InvoiceStatus; } +/** Options for searchByMemo. */ +export interface SearchByMemoOptions { + /** When true, requires exact case matching. Default is false (case-insensitive). */ + caseSensitive?: boolean; +} + +/** + * Filter invoices where the memo field contains the query substring. + * + * @param invoices - Array of invoices to search + * @param query - Substring to search for within each invoice's memo + * @param opts - Search options (caseSensitive defaults to false) + * @returns Filtered array of matching invoices + */ +export function searchByMemo( + invoices: Invoice[], + query: string, + opts?: SearchByMemoOptions +): Invoice[] { + if (!query) { + return invoices; + } + + const caseSensitive = opts?.caseSensitive ?? false; + const normalizedQuery = caseSensitive ? query : query.toLowerCase(); + + return invoices.filter((invoice) => { + if (invoice.memo == null) { + return false; + } + const target = caseSensitive ? invoice.memo : invoice.memo.toLowerCase(); + return target.includes(normalizedQuery); + }); +} + /** * Search invoices by partial criteria using Horizon API. * diff --git a/src/ttlExtension.ts b/src/ttlExtension.ts index 94ba262..10244ac 100644 --- a/src/ttlExtension.ts +++ b/src/ttlExtension.ts @@ -1,5 +1,6 @@ import { Account, + Address, Contract, Operation, TransactionBuilder, diff --git a/test/dedup.test.ts b/test/dedup.test.ts new file mode 100644 index 0000000..778d56e --- /dev/null +++ b/test/dedup.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + generateIdempotencyKey, + isKnownKey, + registerKey, + clearKeys, + Deduplicator, +} from "../src/dedup.js"; + +describe("generateIdempotencyKey & key registry", () => { + beforeEach(() => { + clearKeys(); + }); + + const baseParams = { + invoiceId: "inv-100", + payer: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGXDECKXOWBLNL2WSRB", + amount: 5000000n, + }; + + it("produces deterministic key: same params -> same key", () => { + const key1 = generateIdempotencyKey(baseParams); + const key2 = generateIdempotencyKey({ ...baseParams }); + expect(key1).toBe(key2); + expect(typeof key1).toBe("string"); + expect(key1).toHaveLength(64); // SHA-256 hex string + }); + + it("produces different keys when amount differs", () => { + const key1 = generateIdempotencyKey(baseParams); + const key2 = generateIdempotencyKey({ ...baseParams, amount: 6000000n }); + expect(key1).not.toBe(key2); + }); + + it("produces different keys when nonce is provided or differs", () => { + const keyWithoutNonce = generateIdempotencyKey(baseParams); + const keyWithNonce1 = generateIdempotencyKey({ ...baseParams, nonce: "n1" }); + const keyWithNonce2 = generateIdempotencyKey({ ...baseParams, nonce: "n2" }); + + expect(keyWithoutNonce).not.toBe(keyWithNonce1); + expect(keyWithNonce1).not.toBe(keyWithNonce2); + }); + + it("tracks key registration with isKnownKey after registerKey", () => { + const key = generateIdempotencyKey(baseParams); + expect(isKnownKey(key)).toBe(false); + + registerKey(key); + expect(isKnownKey(key)).toBe(true); + }); + + it("clearKeys resets module state", () => { + const key1 = generateIdempotencyKey(baseParams); + const key2 = generateIdempotencyKey({ ...baseParams, nonce: "nonce-xyz" }); + + registerKey(key1); + registerKey(key2); + expect(isKnownKey(key1)).toBe(true); + expect(isKnownKey(key2)).toBe(true); + + clearKeys(); + expect(isKnownKey(key1)).toBe(false); + expect(isKnownKey(key2)).toBe(false); + }); + + it("Deduplicator handles deduplication of concurrent operations", async () => { + const deduplicator = new Deduplicator(); + let calls = 0; + const worker = async () => { + calls++; + await new Promise((r) => setTimeout(r, 10)); + return 42; + }; + + const [r1, r2] = await Promise.all([ + deduplicator.dedupe("k1", worker), + deduplicator.dedupe("k1", worker), + ]); + + expect(r1).toBe(42); + expect(r2).toBe(42); + expect(calls).toBe(1); + expect(deduplicator.cacheHitRate).toBe(0.5); + }); +}); diff --git a/test/loadTesting.test.ts b/test/loadTesting.test.ts new file mode 100644 index 0000000..8c2305f --- /dev/null +++ b/test/loadTesting.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { + LoadTester, + computeLatencyStats, + formatLoadTestReport, +} from "../src/loadTesting.js"; + +describe("LoadTester (Issue #982)", () => { + it("computes accurate latency statistics and percentiles", () => { + const samples = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const stats = computeLatencyStats(samples); + + expect(stats.min).toBe(10); + expect(stats.max).toBe(100); + expect(stats.mean).toBe(55); + expect(stats.median).toBe(50); + expect(stats.p90).toBe(90); + expect(stats.p95).toBe(100); + expect(stats.p99).toBe(100); + }); + + it("handles empty latency arrays gracefully", () => { + const stats = computeLatencyStats([]); + expect(stats.min).toBe(0); + expect(stats.max).toBe(0); + expect(stats.mean).toBe(0); + }); + + it("executes tasks with concurrency and generates complete report", async () => { + const tester = new LoadTester(); + let counter = 0; + + const report = await tester.run( + { + name: "Concurrent Invoice Processing", + concurrency: 4, + iterations: 20, + }, + async () => { + counter++; + await new Promise((r) => setTimeout(r, 5)); + return counter; + } + ); + + expect(counter).toBe(20); + expect(report.totalIterations).toBe(20); + expect(report.successCount).toBe(20); + expect(report.failureCount).toBe(0); + expect(report.errorRate).toBe(0); + expect(report.concurrency).toBe(4); + expect(report.throughputRps).toBeGreaterThan(0); + expect(report.latency.min).toBeGreaterThan(0); + }); + + it("dispatches all lifecycle events properly", async () => { + const tester = new LoadTester(); + const startListener = vi.fn(); + const iterStartListener = vi.fn(); + const iterEndListener = vi.fn(); + const errorListener = vi.fn(); + const completeListener = vi.fn(); + + tester.on("start", startListener); + tester.on("iterationStart", iterStartListener); + tester.on("iterationEnd", iterEndListener); + tester.on("error", errorListener); + tester.on("complete", completeListener); + + await tester.run( + { + concurrency: 2, + iterations: 4, + }, + async ({ iteration }) => { + if (iteration === 2) { + throw new Error("Simulated failure"); + } + return "ok"; + } + ); + + expect(startListener).toHaveBeenCalledTimes(1); + expect(iterStartListener).toHaveBeenCalledTimes(4); + expect(iterEndListener).toHaveBeenCalledTimes(4); + expect(errorListener).toHaveBeenCalledTimes(1); + expect(completeListener).toHaveBeenCalledTimes(1); + }); + + it("captures error breakdowns when iterations fail", async () => { + const tester = new LoadTester(); + + const report = await tester.run( + { + concurrency: 2, + iterations: 10, + }, + async ({ iteration }) => { + if (iteration % 2 === 1) { + throw new Error("Network timeout"); + } + } + ); + + expect(report.totalIterations).toBe(10); + expect(report.successCount).toBe(5); + expect(report.failureCount).toBe(5); + expect(report.errorRate).toBe(0.5); + expect(report.errorBreakdown["Network timeout"]).toBe(5); + }); + + it("respects durationMs limit", async () => { + const tester = new LoadTester(); + + const report = await tester.run( + { + concurrency: 2, + durationMs: 50, + }, + async () => { + await new Promise((r) => setTimeout(r, 10)); + } + ); + + expect(report.totalIterations).toBeGreaterThan(0); + expect(report.durationMs).toBeGreaterThanOrEqual(45); + }); + + it("formats human-readable report string", async () => { + const tester = new LoadTester(); + + const report = await tester.run( + { + name: "Benchmark Scenario", + concurrency: 1, + iterations: 2, + }, + async () => "ok" + ); + + const formatted = formatLoadTestReport(report); + expect(formatted).toContain("Benchmark Scenario"); + expect(formatted).toContain("Total Operations: 2"); + expect(formatted).toContain("ops/sec"); + }); + + it("allows removing event listeners with off()", async () => { + const tester = new LoadTester(); + const listener = vi.fn(); + + tester.on("start", listener); + tester.off("start", listener); + + await tester.run({ iterations: 1 }, async () => {}); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/test/requestCache.test.ts b/test/requestCache.test.ts new file mode 100644 index 0000000..06f5f5e --- /dev/null +++ b/test/requestCache.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import { RequestCache, generateCacheKey } from "../src/requestCache.js"; +import type { Invoice } from "../src/types.js"; + +function dummyInvoice(id: string): Invoice { + return { + id, + creator: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGXDECKXOWBLNL2WSRB", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + totalAmount: 1000n, + fundedAmount: 0n, + status: "Active", + deadline: Math.floor(Date.now() / 1000) + 3600, + recipients: [], + }; +} + +describe("RequestCache (Issue #1131)", () => { + describe("generateCacheKey", () => { + it("handles key collisions across namespaces", () => { + const k1 = generateCacheKey("invoice", { id: "1" }); + const k2 = generateCacheKey("account", { id: "1" }); + expect(k1).not.toBe(k2); + }); + + it("generates deterministic keys regardless of object key order", () => { + const k1 = generateCacheKey("query", { a: 1, b: 2 }); + const k2 = generateCacheKey("query", { b: 2, a: 1 }); + expect(k1).toBe(k2); + }); + + it("handles primitives, null, and undefined safely", () => { + expect(generateCacheKey("ns", null)).toBe("ns:_nil_"); + expect(generateCacheKey("ns", undefined)).toBe("ns:_nil_"); + expect(generateCacheKey("ns", "scalar")).toBe("ns:scalar"); + expect(generateCacheKey("ns", 123)).toBe("ns:123"); + }); + }); + + describe("TTL and freshness", () => { + it("respects TTL and does not serve stale data", async () => { + const cache = new RequestCache({ defaultTtlMs: 50 }); + cache.set("key1", "value1"); + + expect(cache.get("key1")).toBe("value1"); + + await new Promise((r) => setTimeout(r, 60)); + + expect(cache.get("key1")).toBeUndefined(); + expect(cache.has("key1")).toBe(false); + }); + + it("supports per-entry TTL override", async () => { + const cache = new RequestCache({ defaultTtlMs: 500 }); + cache.set("short", "val1", 40); + cache.set("long", "val2", 300); + + await new Promise((r) => setTimeout(r, 60)); + + expect(cache.get("short")).toBeUndefined(); + expect(cache.get("long")).toBe("val2"); + }); + }); + + describe("RPC call reduction (40%+ requirement)", () => { + it("reduces repeated RPC calls by over 40% (achieves 80%+ reduction)", async () => { + const cache = new RequestCache(); + const mockRpcFetch = vi.fn().mockImplementation(async (id: string) => { + return dummyInvoice(id); + }); + + // Simulate 10 queries where 8 are repeated requests for existing items + const querySequence = ["1", "1", "1", "2", "2", "1", "3", "3", "2", "1"]; + + for (const id of querySequence) { + await cache.getOrFetch(generateCacheKey("invoice", id), () => mockRpcFetch(id)); + } + + // Out of 10 requests, mockRpcFetch should only have been called 3 times (once per unique id) + expect(mockRpcFetch).toHaveBeenCalledTimes(3); + + const stats = cache.getStats(); + expect(stats.totalRequests).toBe(10); + expect(stats.hits).toBe(7); + expect(stats.misses).toBe(3); + // 70% hit rate, which is well above the 40%+ acceptance criterion + expect(stats.hitRate).toBe(0.7); + }); + }); + + describe("Capacity limits and LRU eviction", () => { + it("controls memory usage by evicting least-recently used items when capacity is reached", () => { + const cache = new RequestCache({ maxEntries: 3 }); + + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + + // Access "a" to make it more recently used than "b" + expect(cache.get("a")).toBe("1"); + + // Insert "d", which should evict oldest unaccessed item ("b") + cache.set("d", "4"); + + expect(cache.get("a")).toBe("1"); + expect(cache.get("b")).toBeUndefined(); // evicted + expect(cache.get("c")).toBe("3"); + expect(cache.get("d")).toBe("4"); + + const stats = cache.getStats(); + expect(stats.evictions).toBe(1); + expect(stats.size).toBe(3); + }); + }); + + describe("Cache invalidation strategies", () => { + it("invalidates by key", () => { + const cache = new RequestCache(); + cache.set("k1", "v1"); + expect(cache.invalidate("k1")).toBe(true); + expect(cache.get("k1")).toBeUndefined(); + }); + + it("invalidates by namespace prefix", () => { + const cache = new RequestCache(); + cache.set(generateCacheKey("invoice", "1"), "inv1"); + cache.set(generateCacheKey("invoice", "2"), "inv2"); + cache.set(generateCacheKey("account", "G1"), "acc1"); + + const count = cache.invalidateNamespace("invoice"); + expect(count).toBe(2); + expect(cache.get(generateCacheKey("invoice", "1"))).toBeUndefined(); + expect(cache.get(generateCacheKey("account", "G1"))).toBe("acc1"); + }); + + it("invalidates by predicate", () => { + const cache = new RequestCache(); + cache.set("item:1", 10); + cache.set("item:2", 20); + cache.set("item:3", 30); + + const count = cache.invalidateByPredicate((_key, val) => val >= 20); + expect(count).toBe(2); + expect(cache.get("item:1")).toBe(10); + expect(cache.get("item:2")).toBeUndefined(); + expect(cache.get("item:3")).toBeUndefined(); + }); + + it("clears entire cache", () => { + const cache = new RequestCache(); + cache.set("k1", "v1"); + cache.set("k2", "v2"); + cache.clear(); + expect(cache.getStats().size).toBe(0); + expect(cache.get("k1")).toBeUndefined(); + }); + }); + + describe("Specialized query helpers", () => { + it("caches and invalidates invoice queries", () => { + const cache = new RequestCache(); + const inv = dummyInvoice("123"); + + cache.cacheInvoice("123", inv); + expect(cache.getCachedInvoice("123")).toEqual(inv); + + cache.invalidateInvoice("123"); + expect(cache.getCachedInvoice("123")).toBeUndefined(); + }); + + it("caches and invalidates account information", () => { + const cache = new RequestCache(); + const accountInfo = { sequence: "12345", balances: [] }; + + cache.cacheAccount("GABCD", accountInfo); + expect(cache.getCachedAccount("GABCD")).toEqual(accountInfo); + + cache.invalidateAccount("GABCD"); + expect(cache.getCachedAccount("GABCD")).toBeUndefined(); + }); + + it("caches and retrieves statistics", () => { + const cache = new RequestCache(); + const statsData = { totalInvoices: 42, volumeStroops: 1000000n }; + + cache.cacheStats("daily-volume", statsData); + expect(cache.getCachedStats("daily-volume")).toEqual(statsData); + }); + }); +}); diff --git a/test/searchByMemo.test.ts b/test/searchByMemo.test.ts new file mode 100644 index 0000000..10af4e4 --- /dev/null +++ b/test/searchByMemo.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { searchByMemo } from "../src/search.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(id: string, memo?: string): Invoice { + return { + id, + creator: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFTGXDECKXOWBLNL2WSRB", + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + totalAmount: 1000n, + fundedAmount: 0n, + status: "Active", + deadline: Math.floor(Date.now() / 1000) + 3600, + recipients: [], + memo, + }; +} + +describe("searchByMemo", () => { + const invoices: Invoice[] = [ + makeInvoice("1", "split:INV-001 monthly rent"), + makeInvoice("2", "DINNER with Friends"), + makeInvoice("3", "Project Milestone #2"), + makeInvoice("4", undefined), + { ...makeInvoice("5"), memo: undefined as unknown as string }, + ]; + + it("finds invoices matching substring with default case-insensitivity", () => { + const results = searchByMemo(invoices, "dinner"); + expect(results.map((i) => i.id)).toEqual(["2"]); + + const splitResults = searchByMemo(invoices, "inv-001"); + expect(splitResults.map((i) => i.id)).toEqual(["1"]); + }); + + it("respects caseSensitive: true override", () => { + const caseInsensitive = searchByMemo(invoices, "dinner", { caseSensitive: false }); + expect(caseInsensitive.map((i) => i.id)).toEqual(["2"]); + + const caseSensitiveMismatch = searchByMemo(invoices, "dinner", { caseSensitive: true }); + expect(caseSensitiveMismatch).toEqual([]); + + const caseSensitiveMatch = searchByMemo(invoices, "DINNER", { caseSensitive: true }); + expect(caseSensitiveMatch.map((i) => i.id)).toEqual(["2"]); + }); + + it("returns all invoices unchanged when query is empty string", () => { + const results = searchByMemo(invoices, ""); + expect(results).toBe(invoices); + }); + + it("skips undefined and null memo without throwing error", () => { + const invoicesWithNull: Invoice[] = [ + makeInvoice("10", "valid memo"), + { ...makeInvoice("11"), memo: null as unknown as string }, + { ...makeInvoice("12"), memo: undefined }, + ]; + + expect(() => searchByMemo(invoicesWithNull, "valid")).not.toThrow(); + const matches = searchByMemo(invoicesWithNull, "valid"); + expect(matches.map((i) => i.id)).toEqual(["10"]); + }); + + it("returns empty array when query does not match any invoice memo", () => { + const results = searchByMemo(invoices, "nonexistent-query-string"); + expect(results).toEqual([]); + }); +});