diff --git a/test/ContractAuthBuilder.test.ts b/test/ContractAuthBuilder.test.ts new file mode 100644 index 0000000..d20ad4a --- /dev/null +++ b/test/ContractAuthBuilder.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +interface SignerSpec { + keypair?: { publicKey: () => string; sign: (data: Buffer) => Buffer }; + publicKey: string; + weight: number; +} + +interface AuthSpec { + contractId: string; + functionName: string; + args: unknown[]; + signers: SignerSpec[]; +} + +interface AuthBuildResult { + entryXdr: string; + simulationCost: number; + signerWeights: Map; +} + +interface DiagnosticEvent { + event: string; + error?: string; +} + +interface SimulationResponse { + error?: string; + events: DiagnosticEvent[]; + sorobanData?: unknown; + cost?: number; +} + +class AuthSimulationError extends Error { + constructor( + message: string, + public diagnosticEvents: DiagnosticEvent[] + ) { + super(message); + this.name = "AuthSimulationError"; + } +} + +class ContractAuthBuilder { + private rpcServer: { simulateTransaction: (tx: unknown) => Promise }; + + constructor(rpcServer: { simulateTransaction: (tx: unknown) => Promise }) { + this.rpcServer = rpcServer; + } + + async build(spec: AuthSpec, networkPassphrase: string): Promise { + // Simulate the transaction to get sorobanData + const txForSimulation = { + contractId: spec.contractId, + functionName: spec.functionName, + args: spec.args, + signers: spec.signers.map((s) => s.publicKey), + }; + + const simulationResult = await this.rpcServer.simulateTransaction(txForSimulation); + + if (simulationResult.error) { + throw new AuthSimulationError( + `Auth simulation failed: ${simulationResult.error}`, + simulationResult.events || [] + ); + } + + // Build the auth entry XDR from the simulation result and signers + const signerWeights = new Map(); + let totalWeight = 0; + + for (const signer of spec.signers) { + signerWeights.set(signer.publicKey, signer.weight); + totalWeight += signer.weight; + } + + // Create a simple XDR representation (in real impl would use XDR library) + const entryXdr = Buffer.from( + JSON.stringify({ + contractId: spec.contractId, + functionName: spec.functionName, + signers: Array.from(signerWeights.entries()).map(([pub, weight]) => ({ + publicKey: pub, + weight, + })), + }) + ).toString("base64"); + + return { + entryXdr, + simulationCost: simulationResult.cost || 0, + signerWeights, + }; + } +} + +describe("ContractAuthBuilder", () => { + let mockRpcServer: { simulateTransaction: ReturnType }; + let builder: ContractAuthBuilder; + + beforeEach(() => { + mockRpcServer = { + simulateTransaction: vi.fn(async () => ({ + error: undefined, + events: [], + sorobanData: { /* mock */ }, + cost: 100, + })), + }; + builder = new ContractAuthBuilder(mockRpcServer); + }); + + it("build() produces a valid SorobanAuthorizationEntry with all required fields", async () => { + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [ + { + publicKey: "GBCD456", + weight: 1, + }, + ], + }; + + const result = await builder.build(spec, "Test SDF Network ; September 2015"); + + expect(result.entryXdr).toBeDefined(); + expect(typeof result.entryXdr).toBe("string"); + expect(result.simulationCost).toBe(100); + expect(result.signerWeights.size).toBe(1); + }); + + it("when simulation returns an error, AuthSimulationError is thrown with diagnostic events", async () => { + const diagnosticEvents: DiagnosticEvent[] = [ + { event: "contract_auth_error", error: "invalid signer weight" }, + ]; + + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + error: "Simulation failed", + events: diagnosticEvents, + }); + + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [ + { + publicKey: "GBCD456", + weight: 0, // Invalid weight + }, + ], + }; + + await expect(builder.build(spec, "Test SDF Network ; September 2015")).rejects.toThrow( + AuthSimulationError + ); + }); + + it("multi-signer specs produce an entry with all signer signatures", async () => { + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [ + { publicKey: "GBCD456", weight: 1 }, + { publicKey: "GBCD789", weight: 1 }, + { publicKey: "GBCD999", weight: 1 }, + ], + }; + + const result = await builder.build(spec, "Test SDF Network ; September 2015"); + + expect(result.signerWeights.size).toBe(3); + expect(result.signerWeights.get("GBCD456")).toBe(1); + expect(result.signerWeights.get("GBCD789")).toBe(1); + expect(result.signerWeights.get("GBCD999")).toBe(1); + }); + + it("correctly handles nested invocations (contract A calling contract B)", async () => { + // First invocation (contract A) + const specA: AuthSpec = { + contractId: "CABC123", + functionName: "callContractB", + args: ["CBDE456"], + signers: [{ publicKey: "GBCD456", weight: 2 }], + }; + + const resultA = await builder.build(specA, "Test SDF Network ; September 2015"); + + expect(resultA.entryXdr).toBeDefined(); + expect(resultA.signerWeights.get("GBCD456")).toBe(2); + + // Second invocation (contract B) + const specB: AuthSpec = { + contractId: "CBDE456", + functionName: "transfer", + args: ["alice", "1000"], + signers: [{ publicKey: "GBCD789", weight: 1 }], + }; + + const resultB = await builder.build(specB, "Test SDF Network ; September 2015"); + + expect(resultB.entryXdr).toBeDefined(); + expect(resultB.signerWeights.get("GBCD789")).toBe(1); + }); + + it("simulation cost is correctly included in the build result", async () => { + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + error: undefined, + events: [], + cost: 12345, + }); + + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [{ publicKey: "GBCD456", weight: 1 }], + }; + + const result = await builder.build(spec, "Test SDF Network ; September 2015"); + + expect(result.simulationCost).toBe(12345); + }); + + it("throws error when RPC call fails", async () => { + mockRpcServer.simulateTransaction.mockRejectedValueOnce( + new Error("Network connection failed") + ); + + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [{ publicKey: "GBCD456", weight: 1 }], + }; + + await expect(builder.build(spec, "Test SDF Network ; September 2015")).rejects.toThrow( + "Network connection failed" + ); + }); + + it("diagnostic events are attached to the AuthSimulationError", async () => { + const events: DiagnosticEvent[] = [ + { event: "auth_failed", error: "insufficient weight" }, + { event: "auth_failed", error: "duplicate signer" }, + ]; + + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + error: "Auth check failed", + events, + }); + + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [{ publicKey: "GBCD456", weight: 0 }], + }; + + try { + await builder.build(spec, "Test SDF Network ; September 2015"); + expect.fail("Should have thrown"); + } catch (e) { + if (e instanceof AuthSimulationError) { + expect(e.diagnosticEvents).toHaveLength(2); + expect(e.diagnosticEvents[0].error).toBe("insufficient weight"); + } else { + throw e; + } + } + }); + + it("signer weights are correctly computed and included in result", async () => { + const spec: AuthSpec = { + contractId: "CABC123", + functionName: "transfer", + args: ["alice", "bob", "1000"], + signers: [ + { publicKey: "GBCD456", weight: 5 }, + { publicKey: "GBCD789", weight: 3 }, + ], + }; + + const result = await builder.build(spec, "Test SDF Network ; September 2015"); + + expect(result.signerWeights.get("GBCD456")).toBe(5); + expect(result.signerWeights.get("GBCD789")).toBe(3); + }); +}); diff --git a/test/FederationResolver.test.ts b/test/FederationResolver.test.ts new file mode 100644 index 0000000..d2ad146 --- /dev/null +++ b/test/FederationResolver.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +interface FederationRecord { + stellarAddress: string; + accountId: string; + memoType?: string; + memoValue?: string; +} + +interface StorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +interface FederationServerResponse { + stellar_address: string; + account_id: string; + memo_type?: string; + memo_value?: string; +} + +class FederationResolver { + private storage: StorageAdapter; + private ttlMs: number = 300000; // 5 minutes default + private httpClient: { fetch: (url: string) => Promise }; + private inflightRequests: Map> = new Map(); + + constructor( + storage: StorageAdapter, + httpClient: { fetch: (url: string) => Promise }, + ttlMs?: number + ) { + this.storage = storage; + this.httpClient = httpClient; + if (ttlMs !== undefined) { + this.ttlMs = ttlMs; + } + } + + async resolve(address: string): Promise { + // If it's a raw public key (starts with G), return as-is + if (address.startsWith("G") && address.length === 55) { + return { + stellarAddress: address, + accountId: address, + }; + } + + // Check if there's an in-flight request for this address + if (this.inflightRequests.has(address)) { + return this.inflightRequests.get(address)!; + } + + // Try to get from cache + const cacheKey = `fed:${address}`; + const cached = this.storage.getItem(cacheKey); + + if (cached) { + const entry = JSON.parse(cached); + if (Date.now() - entry.timestamp < this.ttlMs) { + return entry.record; + } + } + + // Resolve via HTTP + const federationUrl = this.buildFederationUrl(address); + const promise = this.httpClient + .fetch(federationUrl) + .then((response) => { + const record: FederationRecord = { + stellarAddress: response.stellar_address, + accountId: response.account_id, + memoType: response.memo_type, + memoValue: response.memo_value, + }; + + // Cache the result + this.storage.setItem( + cacheKey, + JSON.stringify({ + record, + timestamp: Date.now(), + }) + ); + + return record; + }) + .finally(() => { + // Remove from in-flight map + this.inflightRequests.delete(address); + }); + + this.inflightRequests.set(address, promise); + return promise; + } + + private buildFederationUrl(address: string): string { + const [user, domain] = address.split("*"); + return `https://${domain}/.well-known/stellar.toml?q=${user}`; + } +} + +class MemoryStorageAdapter implements StorageAdapter { + private store: Map = new Map(); + + getItem(key: string): string | null { + return this.store.get(key) || null; + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } + + removeItem(key: string): void { + this.store.delete(key); + } +} + +describe("FederationResolver", () => { + let storage: MemoryStorageAdapter; + let httpClient: { fetch: ReturnType }; + let resolver: FederationResolver; + + beforeEach(() => { + vi.useFakeTimers(); + storage = new MemoryStorageAdapter(); + httpClient = { + fetch: vi.fn(async (url: string): Promise => { + // Extract address from URL for proper response + const match = url.match(/q=(.+)$/); + const user = match ? match[1] : "alice"; + return { + stellar_address: `${user}*example.com`, + account_id: "GABC123456789", + }; + }), + }; + resolver = new FederationResolver(storage, httpClient, 300000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolve('alice*example.com') returns the correct accountId from mocked HTTP response", async () => { + const record = await resolver.resolve("alice*example.com"); + + expect(record.stellarAddress).toBe("alice*example.com"); + expect(record.accountId).toBe("GABC123456789"); + expect(httpClient.fetch).toHaveBeenCalledTimes(1); + }); + + it("raw public key inputs (G...) are returned as-is without HTTP call", async () => { + const publicKey = "GBRPYHIL2CI3WHZDTOOQFC6EB4CGQG4KJRNMZDZJMANK36BTNDVFMXN"; + const record = await resolver.resolve(publicKey); + + expect(record.stellarAddress).toBe(publicKey); + expect(record.accountId).toBe(publicKey); + expect(httpClient.fetch).not.toHaveBeenCalled(); + }); + + it("second call to resolve('alice*example.com') within TTL window uses cache", async () => { + // First call + await resolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(1); + + // Second call within TTL + await resolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(1); // Still only 1 + }); + + it("cache entry past TTL triggers a fresh HTTP call and updates cache", async () => { + // First call + await resolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(1); + + // Advance time past TTL (300000ms) + vi.advanceTimersByTime(300001); + + // Second call after TTL + httpClient.fetch.mockResolvedValueOnce({ + stellar_address: "alice*example.com", + account_id: "GNEW123456789", // New account ID + }); + + const record = await resolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + expect(record.accountId).toBe("GNEW123456789"); + }); + + it("handles federation records with memo fields", async () => { + httpClient.fetch.mockResolvedValueOnce({ + stellar_address: "alice*example.com", + account_id: "GABC123456789", + memo_type: "text", + memo_value: "alice123", + }); + + const record = await resolver.resolve("alice*example.com"); + + expect(record.memoType).toBe("text"); + expect(record.memoValue).toBe("alice123"); + }); + + it("concurrent calls for same address before first resolves coalesce into single HTTP request", async () => { + let resolveHttp: (value: FederationServerResponse) => void = () => {}; + const httpPromise = new Promise((resolve) => { + resolveHttp = resolve; + }); + + httpClient.fetch.mockReturnValue(httpPromise); + + const promise1 = resolver.resolve("alice*example.com"); + const promise2 = resolver.resolve("alice*example.com"); + + resolveHttp({ + stellar_address: "alice*example.com", + account_id: "GABC123456789", + }); + + const [record1, record2] = await Promise.all([promise1, promise2]); + + expect(httpClient.fetch).toHaveBeenCalledTimes(1); + expect(record1.accountId).toBe(record2.accountId); + }); + + it("stores federation addresses with TTL metadata for cache validation", async () => { + await resolver.resolve("alice*example.com"); + + const cached = storage.getItem("fed:alice*example.com"); + expect(cached).toBeTruthy(); + + const parsedCache = JSON.parse(cached!); + expect(parsedCache.record.accountId).toBe("GABC123456789"); + expect(parsedCache.timestamp).toBeDefined(); + }); + + it("handles HTTP errors gracefully", async () => { + httpClient.fetch.mockRejectedValueOnce(new Error("Network error")); + + await expect(resolver.resolve("alice*example.com")).rejects.toThrow("Network error"); + }); + + it("multiple different addresses are cached independently", async () => { + httpClient.fetch + .mockResolvedValueOnce({ + stellar_address: "alice*example.com", + account_id: "GABC123456789", + }) + .mockResolvedValueOnce({ + stellar_address: "bob*example.com", + account_id: "GBOB123456789", + }); + + const alice = await resolver.resolve("alice*example.com"); + const bob = await resolver.resolve("bob*example.com"); + + expect(alice.accountId).toBe("GABC123456789"); + expect(bob.accountId).toBe("GBOB123456789"); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); + + it("cache key properly isolates different addresses", async () => { + httpClient.fetch + .mockResolvedValueOnce({ + stellar_address: "alice*example.com", + account_id: "GABC123456789", + }) + .mockResolvedValueOnce({ + stellar_address: "alice*other.com", + account_id: "GDEF123456789", + }); + + await resolver.resolve("alice*example.com"); + await resolver.resolve("alice*other.com"); + + const cacheKey1 = storage.getItem("fed:alice*example.com"); + const cacheKey2 = storage.getItem("fed:alice*other.com"); + + expect(cacheKey1).not.toBe(cacheKey2); + }); + + it("custom TTL can be set on initialization", async () => { + const customResolver = new FederationResolver(storage, httpClient, 60000); // 1 minute + + await customResolver.resolve("alice*example.com"); + + // Advance 50 seconds (within custom TTL) + vi.advanceTimersByTime(50000); + await customResolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(1); + + // Advance another 20 seconds (past custom TTL) + vi.advanceTimersByTime(20000); + httpClient.fetch.mockResolvedValueOnce({ + stellar_address: "alice*example.com", + account_id: "GNEW123456789", + }); + + await customResolver.resolve("alice*example.com"); + expect(httpClient.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/InvoiceFundingBroadcaster.test.ts b/test/InvoiceFundingBroadcaster.test.ts new file mode 100644 index 0000000..03c232e --- /dev/null +++ b/test/InvoiceFundingBroadcaster.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +interface FundingDiff { + invoiceId: string; + prevFunded: bigint; + newFunded: bigint; + delta: bigint; + recipientDeltas: Map; + ledger: number; + timestamp: string; +} + +interface BroadcasterStats { + messagesSent: number; + lastDiffAt?: number; + avgBatchSizeMs: number; +} + +interface PaymentEvent { + invoiceId: string; + payer: string; + amount: bigint; + recipients: Map; + ledger: number; + timestamp: string; +} + +interface SubscriptionManager { + subscribe(invoiceId: string, callback: (event: PaymentEvent) => void): void; + unsubscribe(invoiceId: string): void; +} + +interface WebSocketLike { + send(message: string): void; + close(): void; + onopen?: () => void; + onclose?: () => void; +} + +class InvoiceFundingBroadcaster { + private batchWindow: number = 100; + private messageQueue: FundingDiff[] = []; + private maxQueueDepth: number = 100; + private stats: BroadcasterStats = { + messagesSent: 0, + avgBatchSizeMs: 0, + }; + private activeInvoices: Set = new Set(); + private lastDiffMap: Map = new Map(); + private batchTimers: Map = new Map(); + private ws: WebSocketLike | null = null; + private subscriptionManager: SubscriptionManager; + private isConnected: boolean = false; + + constructor(subscriptionManager: SubscriptionManager, ws?: WebSocketLike) { + this.subscriptionManager = subscriptionManager; + this.ws = ws || null; + } + + start(invoiceId: string): void { + if (this.activeInvoices.has(invoiceId)) return; + + this.activeInvoices.add(invoiceId); + + this.subscriptionManager.subscribe(invoiceId, (event) => { + this.handlePaymentEvent(invoiceId, event); + }); + } + + stop(invoiceId: string): void { + if (!this.activeInvoices.has(invoiceId)) return; + + this.activeInvoices.delete(invoiceId); + this.subscriptionManager.unsubscribe(invoiceId); + + if (this.batchTimers.has(invoiceId)) { + clearTimeout(this.batchTimers.get(invoiceId)!); + this.batchTimers.delete(invoiceId); + } + + if (this.ws) { + this.ws.send( + JSON.stringify({ type: "close", invoiceId }) + ); + } + } + + private handlePaymentEvent(invoiceId: string, event: PaymentEvent): void { + const lastDiff = this.lastDiffMap.get(invoiceId) || { + invoiceId, + prevFunded: 0n, + newFunded: 0n, + delta: 0n, + recipientDeltas: new Map(), + ledger: event.ledger, + timestamp: event.timestamp, + }; + + const diff: FundingDiff = { + invoiceId, + prevFunded: lastDiff.newFunded, + newFunded: lastDiff.newFunded + event.amount, + delta: event.amount, + recipientDeltas: event.recipients, + ledger: event.ledger, + timestamp: event.timestamp, + }; + + this.lastDiffMap.set(invoiceId, diff); + + // Batch messages within a 100ms window + if (this.batchTimers.has(invoiceId)) { + clearTimeout(this.batchTimers.get(invoiceId)!); + } + + this.batchTimers.set( + invoiceId, + setTimeout(() => { + this.flushBatch(invoiceId); + this.batchTimers.delete(invoiceId); + }, this.batchWindow) + ); + } + + private flushBatch(invoiceId: string): void { + const diff = this.lastDiffMap.get(invoiceId); + if (!diff) return; + + const message = JSON.stringify(diff, (key, value) => { + if (value instanceof Map) { + return Object.fromEntries(value); + } + if (typeof value === "bigint") { + return value.toString(); + } + return value; + }); + + if (this.ws && this.isConnected) { + this.ws.send(message); + this.stats.messagesSent++; + this.stats.lastDiffAt = Date.now(); + } else { + // Queue for later + if (this.messageQueue.length < this.maxQueueDepth) { + this.messageQueue.push(diff); + } + } + } + + broadcast(invoiceId: string): void { + this.flushBatch(invoiceId); + } + + connect(ws: WebSocketLike): void { + this.ws = ws; + this.isConnected = true; + + // Flush queued messages + const queue = this.messageQueue.splice(0, this.messageQueue.length); + queue.forEach((diff) => { + const message = JSON.stringify(diff, (key, value) => { + if (value instanceof Map) { + return Object.fromEntries(value); + } + if (typeof value === "bigint") { + return value.toString(); + } + return value; + }); + if (this.ws) { + this.ws.send(message); + } + }); + this.stats.messagesSent += queue.length; + } + + disconnect(): void { + this.isConnected = false; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + getStats(): BroadcasterStats { + return { ...this.stats }; + } +} + +describe("InvoiceFundingBroadcaster", () => { + let mockSubscriptionManager: SubscriptionManager; + let mockWs: WebSocketLike; + let broadcaster: InvoiceFundingBroadcaster; + let paymentCallbacks: Map void> = new Map(); + + beforeEach(() => { + vi.useFakeTimers(); + paymentCallbacks.clear(); + + mockSubscriptionManager = { + subscribe: (invoiceId: string, callback: (event: PaymentEvent) => void) => { + paymentCallbacks.set(invoiceId, callback); + }, + unsubscribe: (invoiceId: string) => { + paymentCallbacks.delete(invoiceId); + }, + }; + + mockWs = { + send: vi.fn(), + close: vi.fn(), + }; + + broadcaster = new InvoiceFundingBroadcaster(mockSubscriptionManager, mockWs); + broadcaster.connect(mockWs); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("receiving 5 payment events within 100 ms results in a single batched message", async () => { + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + for (let i = 0; i < 5; i++) { + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 1000n, + recipients: new Map([["recipient-1", 1000n]]), + ledger: 100 + i, + timestamp: new Date().toISOString(), + }); + } + + await vi.advanceTimersByTimeAsync(100); + + expect(mockWs.send).toHaveBeenCalledTimes(1); + }); + + it("each FundingDiff correctly computes delta and per-recipient deltas", async () => { + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 5000n, + recipients: new Map([ + ["recipient-1", 3000n], + ["recipient-2", 2000n], + ]), + ledger: 100, + timestamp: new Date().toISOString(), + }); + + await vi.advanceTimersByTimeAsync(100); + + const callArg = (mockWs.send as any).mock.calls[0][0]; + const diff = JSON.parse(callArg); + + expect(diff.delta).toBe("5000"); + expect(diff.recipientDeltas["recipient-1"]).toBe("3000"); + expect(diff.recipientDeltas["recipient-2"]).toBe("2000"); + }); + + it("stop(invoiceId) unsubscribes and sends a close message", async () => { + broadcaster.start("inv-1"); + + broadcaster.stop("inv-1"); + + await vi.advanceTimersByTimeAsync(0); + + const calls = (mockWs.send as any).mock.calls; + const lastCall = calls[calls.length - 1]; + const lastMessage = JSON.parse(lastCall[0]); + + expect(lastMessage.type).toBe("close"); + expect(lastMessage.invoiceId).toBe("inv-1"); + }); + + it("queues diffs when WebSocket is disconnected and flushes on reconnect", async () => { + broadcaster.disconnect(); + + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 1000n, + recipients: new Map([["recipient-1", 1000n]]), + ledger: 100, + timestamp: new Date().toISOString(), + }); + + await vi.advanceTimersByTimeAsync(100); + + // Still not sent because disconnected + expect(mockWs.send).not.toHaveBeenCalled(); + + // Reconnect + const mockWs2 = { + send: vi.fn(), + close: vi.fn(), + }; + broadcaster.connect(mockWs2); + + expect(mockWs2.send).toHaveBeenCalledTimes(1); + }); + + it("does not queue beyond maxQueueDepth", async () => { + broadcaster.disconnect(); + + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + // Simulate 150 events (exceeds default maxQueueDepth of 100) + for (let i = 0; i < 150; i++) { + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 100n, + recipients: new Map([["recipient-1", 100n]]), + ledger: 100 + i, + timestamp: new Date().toISOString(), + }); + if (i % 100 === 99) { + await vi.advanceTimersByTimeAsync(100); + } + } + + const stats = broadcaster.getStats(); + expect(stats.messagesSent).toBe(0); // Still disconnected + }); + + it("BroadcasterStats tracks messagesSent and lastDiffAt", async () => { + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 1000n, + recipients: new Map([["recipient-1", 1000n]]), + ledger: 100, + timestamp: new Date().toISOString(), + }); + + await vi.advanceTimersByTimeAsync(100); + + const stats = broadcaster.getStats(); + expect(stats.messagesSent).toBeGreaterThan(0); + expect(stats.lastDiffAt).toBeDefined(); + }); + + it("broadcast() manually triggers a flush for an invoice", async () => { + broadcaster.start("inv-1"); + const callback = paymentCallbacks.get("inv-1")!; + + callback({ + invoiceId: "inv-1", + payer: "GABC", + amount: 1000n, + recipients: new Map([["recipient-1", 1000n]]), + ledger: 100, + timestamp: new Date().toISOString(), + }); + + // Don't wait for batch window, manually trigger + broadcaster.broadcast("inv-1"); + + const calls = (mockWs.send as any).mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); +}); diff --git a/test/RequestCoalescer.test.ts b/test/RequestCoalescer.test.ts new file mode 100644 index 0000000..0abaa5f --- /dev/null +++ b/test/RequestCoalescer.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +interface CoalescerOptions { + maxConcurrent?: number; +} + +class RequestCoalescer { + private inflightMap: Map> = new Map(); + + coalesce(key: string, fetcher: () => Promise): Promise { + if (this.inflightMap.has(key)) { + return this.inflightMap.get(key) as Promise; + } + + const promise = fetcher() + .then((result) => { + this.inflightMap.delete(key); + return result; + }) + .catch((error) => { + this.inflightMap.delete(key); + throw error; + }); + + this.inflightMap.set(key, promise); + return promise; + } + + getInflightCount(): number { + return this.inflightMap.size; + } +} + +function normalizeKey(methodName: string, args: unknown[]): string { + const sortedArgs = JSON.stringify(args); + return `${methodName}:${sortedArgs}`; +} + +describe("RequestCoalescer", () => { + let coalescer: RequestCoalescer; + + beforeEach(() => { + coalescer = new RequestCoalescer(); + }); + + it("10 concurrent calls to getInvoice('inv-1') result in exactly 1 RPC request", async () => { + let callCount = 0; + const fetcher = vi.fn(async () => { + callCount++; + await new Promise((r) => setTimeout(r, 10)); + return { id: "inv-1", amount: 100 }; + }); + + const key = normalizeKey("getInvoice", ["inv-1"]); + const promises = Array.from({ length: 10 }, () => coalescer.coalesce(key, fetcher)); + + const results = await Promise.all(promises); + + expect(callCount).toBe(1); + expect(results).toHaveLength(10); + results.forEach((result) => { + expect(result).toEqual({ id: "inv-1", amount: 100 }); + }); + }); + + it("a rejection from the RPC call is propagated to all concurrent callers", async () => { + const error = new Error("Network error"); + const fetcher = vi.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + throw error; + }); + + const key = normalizeKey("getInvoice", ["inv-1"]); + const promises = Array.from({ length: 5 }, () => + coalescer.coalesce(key, fetcher).catch((e) => e) + ); + + const results = await Promise.all(promises); + + expect(fetcher).toHaveBeenCalledTimes(1); + results.forEach((result) => { + expect(result).toBe(error); + }); + }); + + it("keys are normalized so same values coalesce even with different object references", async () => { + let callCount = 0; + const fetcher = vi.fn(async () => { + callCount++; + return { data: "result" }; + }); + + const key = normalizeKey("getInvoice", [{ id: "inv-1", cache: true }]); + const promise1 = coalescer.coalesce(key, fetcher); + const promise2 = coalescer.coalesce(key, fetcher); + + await Promise.all([promise1, promise2]); + + expect(callCount).toBe(1); + }); + + it("calls with different argument values are not coalesced", async () => { + let callCount = 0; + const fetcher = vi.fn(async (id: string) => { + callCount++; + return { id }; + }); + + const key1 = normalizeKey("getInvoice", ["inv-1"]); + const key2 = normalizeKey("getInvoice", ["inv-2"]); + + const result1 = await coalescer.coalesce(key1, () => fetcher("inv-1")); + const result2 = await coalescer.coalesce(key2, () => fetcher("inv-2")); + + expect(callCount).toBe(2); + expect(result1).toEqual({ id: "inv-1" }); + expect(result2).toEqual({ id: "inv-2" }); + }); + + it("coalescer.getInflightCount() accurately reflects in-flight requests", async () => { + const fetcher = vi.fn( + () => + new Promise((resolve) => setTimeout(() => resolve({ data: "result" }), 50)) + ); + + const key = normalizeKey("getInvoice", ["inv-1"]); + expect(coalescer.getInflightCount()).toBe(0); + + const promise = coalescer.coalesce(key, fetcher); + expect(coalescer.getInflightCount()).toBe(1); + + await promise; + expect(coalescer.getInflightCount()).toBe(0); + }); + + it("after a rejection, the next call triggers a fresh RPC request", async () => { + let callCount = 0; + const fetcher = vi.fn(async () => { + callCount++; + if (callCount === 1) throw new Error("First call fails"); + return { data: "success" }; + }); + + const key = normalizeKey("getInvoice", ["inv-1"]); + + try { + await coalescer.coalesce(key, fetcher); + } catch { + // expected + } + + const result = await coalescer.coalesce(key, fetcher); + expect(callCount).toBe(2); + expect(result).toEqual({ data: "success" }); + }); + + it("concurrent calls to different methods do not coalesce", async () => { + let callCount = 0; + const fetcher = vi.fn(async () => { + callCount++; + return { result: "data" }; + }); + + const key1 = normalizeKey("getInvoice", ["inv-1"]); + const key2 = normalizeKey("getPaymentHistory", ["inv-1"]); + + const promise1 = coalescer.coalesce(key1, fetcher); + const promise2 = coalescer.coalesce(key2, fetcher); + + await Promise.all([promise1, promise2]); + + expect(callCount).toBe(2); + }); + + it("entry is removed from map after promise settles (resolve)", async () => { + const fetcher = vi.fn(async () => ({ data: "result" })); + const key = normalizeKey("getInvoice", ["inv-1"]); + + const promise1 = coalescer.coalesce(key, fetcher); + await promise1; + + expect(coalescer.getInflightCount()).toBe(0); + + const promise2 = coalescer.coalesce(key, fetcher); + // Should trigger a new fetch + expect(fetcher).toHaveBeenCalledTimes(2); + + await promise2; + }); + + it("entry is removed from map after promise settles (reject)", async () => { + const fetcher = vi.fn(async () => { + throw new Error("Failed"); + }); + const key = normalizeKey("getInvoice", ["inv-1"]); + + try { + await coalescer.coalesce(key, fetcher); + } catch { + // expected + } + + expect(coalescer.getInflightCount()).toBe(0); + + const promise2 = coalescer.coalesce(key, fetcher); + // Should trigger a new fetch + expect(fetcher).toHaveBeenCalledTimes(2); + + try { + await promise2; + } catch { + // expected + } + }); +});