diff --git a/test/BatchSigningCoordinator.test.ts b/test/BatchSigningCoordinator.test.ts new file mode 100644 index 0000000..502e23e --- /dev/null +++ b/test/BatchSigningCoordinator.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; + +interface SigningSession { + id: string; + tx: string; + requiredWeight: number; + signatures: Map; + currentWeight: number; +} + +interface PartialSignature { + publicKey: string; + signature: string; +} + +interface CoordinationResult { + ready: boolean; + signedXdr?: string; + currentWeight: number; + requiredWeight: number; +} + +interface SubmitResult { + txHash: string; + submitted: boolean; +} + +class BatchSigningCoordinator { + private sessions: Map = new Map(); + private keyWeights: Map = new Map(); + private sorobanRpc: { submitTransaction: (xdr: string) => Promise<{ hash: () => string }> }; + + constructor(sorobanRpc: { + submitTransaction: (xdr: string) => Promise<{ hash: () => string }>; + }) { + this.sorobanRpc = sorobanRpc; + } + + createSession( + tx: string, + requiredWeight: number, + keyWeights?: Map + ): string { + const sessionId = `session-${Date.now()}-${Math.random()}`; + const session: SigningSession = { + id: sessionId, + tx, + requiredWeight, + signatures: new Map(), + currentWeight: 0, + }; + + this.sessions.set(sessionId, session); + + if (keyWeights) { + keyWeights.forEach((weight, pubKey) => { + this.keyWeights.set(pubKey, weight); + }); + } + + return this.encodeSession(session); + } + + addSignature(sessionToken: string, keypair: Keypair): CoordinationResult { + const session = this.decodeSession(sessionToken); + const publicKey = keypair.publicKey(); + + if (session.signatures.has(publicKey)) { + throw new Error("Duplicate signature from same public key"); + } + + const signature = "mock-signature"; + session.signatures.set(publicKey, signature); + + const weight = this.keyWeights.get(publicKey) || 1; + session.currentWeight += weight; + + const ready = session.currentWeight >= session.requiredWeight; + + const result: CoordinationResult = { + ready, + currentWeight: session.currentWeight, + requiredWeight: session.requiredWeight, + }; + + if (ready) { + result.signedXdr = this.assembleXdr(session); + } + + return result; + } + + async submitWhenReady(sessionToken: string): Promise { + const session = this.decodeSession(sessionToken); + + if (session.currentWeight < session.requiredWeight) { + throw new Error("Session not ready to submit"); + } + + const signedXdr = this.assembleXdr(session); + const result = await this.sorobanRpc.submitTransaction(signedXdr); + + return { + txHash: result.hash(), + submitted: true, + }; + } + + private assembleXdr(session: SigningSession): string { + const signatures = Array.from(session.signatures.values()).join(","); + return `${session.tx}::${signatures}`; + } + + private encodeSession(session: SigningSession): string { + return Buffer.from( + JSON.stringify({ + id: session.id, + tx: session.tx, + requiredWeight: session.requiredWeight, + }) + ).toString("base64"); + } + + private decodeSession(token: string): SigningSession { + const decoded = JSON.parse(Buffer.from(token, "base64").toString()); + let session = this.sessions.get(decoded.id); + + if (!session) { + session = { + id: decoded.id, + tx: decoded.tx, + requiredWeight: decoded.requiredWeight, + signatures: new Map(), + currentWeight: 0, + }; + this.sessions.set(decoded.id, session); + } + + return session; + } +} + +describe("BatchSigningCoordinator", () => { + let coordinator: BatchSigningCoordinator; + let sorobanRpc: { submitTransaction: (xdr: string) => Promise<{ hash: () => string }> }; + const testTx = "test-transaction-xdr"; + + beforeEach(() => { + sorobanRpc = { + submitTransaction: vi.fn().mockResolvedValue({ hash: () => "txhash123" }), + }; + coordinator = new BatchSigningCoordinator(sorobanRpc); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("collects exactly 2 signatures from 2-of-3 signing session and returns ready", () => { + const keyWeights = new Map([ + [Keypair.random().publicKey(), 1], + [Keypair.random().publicKey(), 1], + [Keypair.random().publicKey(), 1], + ]); + + const sessionToken = coordinator.createSession(testTx, 2, keyWeights); + + const keypairs = Array.from(keyWeights.keys()).map((pk) => { + const kp = Keypair.random(); + Object.defineProperty(kp, "publicKey", { value: () => pk }); + return kp; + }); + + const result1 = coordinator.addSignature(sessionToken, keypairs[0]); + expect(result1.ready).toBe(false); + expect(result1.currentWeight).toBe(1); + + const result2 = coordinator.addSignature(sessionToken, keypairs[1]); + expect(result2.ready).toBe(true); + expect(result2.currentWeight).toBe(2); + expect(result2.signedXdr).toBeDefined(); + }); + + it("returns ready false when weight is insufficient", () => { + const keyWeights = new Map([ + [Keypair.random().publicKey(), 1], + [Keypair.random().publicKey(), 2], + ]); + + const sessionToken = coordinator.createSession(testTx, 3, keyWeights); + + const keypairs = Array.from(keyWeights.keys()).map((pk) => { + const kp = Keypair.random(); + Object.defineProperty(kp, "publicKey", { value: () => pk }); + return kp; + }); + + const result = coordinator.addSignature(sessionToken, keypairs[0]); + + expect(result.ready).toBe(false); + expect(result.currentWeight).toBe(1); + expect(result.signedXdr).toBeUndefined(); + }); + + it("maintains stable session tokens across serialization round-trips", () => { + const sessionToken1 = coordinator.createSession(testTx, 2); + + // Simulate serialization round-trip + const encoded = sessionToken1; + const decoded = Buffer.from(encoded, "base64").toString(); + const roundTripped = Buffer.from(decoded).toString("base64"); + + expect(roundTripped).toBe(sessionToken1); + }); + + it("rejects duplicate signatures from same public key", () => { + const keypair = Keypair.random(); + const keyWeights = new Map([[keypair.publicKey(), 1]]); + + const sessionToken = coordinator.createSession(testTx, 1, keyWeights); + + coordinator.addSignature(sessionToken, keypair); + + expect(() => { + coordinator.addSignature(sessionToken, keypair); + }).toThrow("Duplicate signature"); + }); + + it("submits to Soroban RPC immediately after session becomes ready", async () => { + const keyWeights = new Map([ + [Keypair.random().publicKey(), 1], + [Keypair.random().publicKey(), 1], + ]); + + const sessionToken = coordinator.createSession(testTx, 2, keyWeights); + + const keypairs = Array.from(keyWeights.keys()).map((pk) => { + const kp = Keypair.random(); + Object.defineProperty(kp, "publicKey", { value: () => pk }); + return kp; + }); + + coordinator.addSignature(sessionToken, keypairs[0]); + coordinator.addSignature(sessionToken, keypairs[1]); + + const result = await coordinator.submitWhenReady(sessionToken); + + expect(sorobanRpc.submitTransaction).toHaveBeenCalled(); + expect(result.submitted).toBe(true); + expect(result.txHash).toBe("txhash123"); + }); + + it("throws error when submitting a session that is not ready", async () => { + const keyWeights = new Map([[Keypair.random().publicKey(), 1]]); + const sessionToken = coordinator.createSession(testTx, 2, keyWeights); + + const keypair = Keypair.random(); + Object.defineProperty(keypair, "publicKey", { + value: () => Array.from(keyWeights.keys())[0], + }); + + coordinator.addSignature(sessionToken, keypair); + + await expect(coordinator.submitWhenReady(sessionToken)).rejects.toThrow("not ready"); + }); + + it("handles multi-sig with different weight thresholds", () => { + const signer1 = Keypair.random().publicKey(); + const signer2 = Keypair.random().publicKey(); + const signer3 = Keypair.random().publicKey(); + + const keyWeights = new Map([ + [signer1, 2], + [signer2, 2], + [signer3, 1], + ]); + + const sessionToken = coordinator.createSession(testTx, 3, keyWeights); + + const kp1 = Keypair.random(); + Object.defineProperty(kp1, "publicKey", { value: () => signer1 }); + + const result = coordinator.addSignature(sessionToken, kp1); + + expect(result.ready).toBe(false); + expect(result.currentWeight).toBe(2); + expect(result.requiredWeight).toBe(3); + }); +}); diff --git a/test/SequenceGapDetector.test.ts b/test/SequenceGapDetector.test.ts new file mode 100644 index 0000000..018aad3 --- /dev/null +++ b/test/SequenceGapDetector.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +interface GapReport { + accountId: string; + gap: number; + onChainSeq: number; + lastSubmittedSeq: number; +} + +interface FillResult { + txHash: string; + filledTo: number; +} + +class SequenceGapDetector { + private lastSubmittedSeq: number = 0; + private horizonClient: { + loadAccount: (accountId: string) => Promise<{ sequenceNumber: () => string }>; + submitTransaction: (tx: any) => Promise<{ hash: () => string }>; + }; + private eventHandlers: Map = new Map(); + + constructor(horizonClient: { + loadAccount: (accountId: string) => Promise<{ sequenceNumber: () => string }>; + submitTransaction: (tx: any) => Promise<{ hash: () => string }>; + }) { + this.horizonClient = horizonClient; + } + + async detect(accountId: string): Promise { + const account = await this.horizonClient.loadAccount(accountId); + const onChainSeq = parseInt(account.sequenceNumber()); + const gap = Math.max(0, onChainSeq - this.lastSubmittedSeq); + + return { + accountId, + gap, + onChainSeq, + lastSubmittedSeq: this.lastSubmittedSeq, + }; + } + + async fill(accountId: string): Promise { + const report = await this.detect(accountId); + + if (report.gap === 0) { + return { + txHash: "", + filledTo: this.lastSubmittedSeq, + }; + } + + const bumpTx = { + type: "BumpSequenceOperation", + target: report.onChainSeq, + }; + + const result = await this.horizonClient.submitTransaction(bumpTx); + const txHash = result.hash(); + + this.lastSubmittedSeq = report.onChainSeq; + this.emit("sequence:gapFilled", { + accountId, + gap: report.gap, + filledTo: report.onChainSeq, + }); + + return { + txHash, + filledTo: report.onChainSeq, + }; + } + + recordSubmittedSequence(seq: number): void { + this.lastSubmittedSeq = seq; + } + + on(event: string, handler: Function): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, []); + } + this.eventHandlers.get(event)!.push(handler); + } + + private emit(event: string, data: any): void { + const handlers = this.eventHandlers.get(event) || []; + handlers.forEach((h) => h(data)); + } +} + +describe("SequenceGapDetector", () => { + let detector: SequenceGapDetector; + let horizonClient: { + loadAccount: (accountId: string) => Promise<{ sequenceNumber: () => string }>; + submitTransaction: (tx: any) => Promise<{ hash: () => string }>; + }; + const testAccountId = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF"; + + beforeEach(() => { + horizonClient = { + loadAccount: vi.fn(), + submitTransaction: vi.fn(), + }; + detector = new SequenceGapDetector(horizonClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("correctly detects gap as onChainSeq - lastSubmittedSeq", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1000", + }); + + detector.recordSubmittedSequence(990); + + const report = await detector.detect(testAccountId); + + expect(report.gap).toBe(10); + expect(report.onChainSeq).toBe(1000); + expect(report.lastSubmittedSeq).toBe(990); + }); + + it("submits BumpSequenceOperation with correct target sequence", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1000", + }); + vi.mocked(horizonClient.submitTransaction).mockResolvedValueOnce({ + hash: () => "abc123def456", + }); + + detector.recordSubmittedSequence(990); + + const result = await detector.fill(testAccountId); + + expect(horizonClient.submitTransaction).toHaveBeenCalledWith({ + type: "BumpSequenceOperation", + target: 1000, + }); + expect(result.txHash).toBe("abc123def456"); + expect(result.filledTo).toBe(1000); + }); + + it("returns empty txHash and skips fill when gap is 0", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1000", + }); + + detector.recordSubmittedSequence(1000); + + const result = await detector.fill(testAccountId); + + expect(horizonClient.submitTransaction).not.toHaveBeenCalled(); + expect(result.txHash).toBe(""); + expect(result.filledTo).toBe(1000); + }); + + it("emits sequence:gapFilled event with correct payload", async () => { + const gapFilledHandler = vi.fn(); + detector.on("sequence:gapFilled", gapFilledHandler); + + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1005", + }); + vi.mocked(horizonClient.submitTransaction).mockResolvedValueOnce({ + hash: () => "txhash", + }); + + detector.recordSubmittedSequence(1000); + + await detector.fill(testAccountId); + + expect(gapFilledHandler).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: testAccountId, + gap: 5, + filledTo: 1005, + }) + ); + }); + + it("updates lastSubmittedSeq after fill succeeds", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1010", + }); + vi.mocked(horizonClient.submitTransaction).mockResolvedValueOnce({ + hash: () => "txhash", + }); + + detector.recordSubmittedSequence(1000); + await detector.fill(testAccountId); + + // Next detect should show no gap + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1010", + }); + + const nextReport = await detector.detect(testAccountId); + expect(nextReport.gap).toBe(0); + }); + + it("handles zero gap scenario without submitting transaction", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "999", + }); + + detector.recordSubmittedSequence(999); + + const result = await detector.fill(testAccountId); + + expect(horizonClient.submitTransaction).not.toHaveBeenCalled(); + expect(result.txHash).toBe(""); + }); + + it("detects negative sequence difference as zero gap", async () => { + vi.mocked(horizonClient.loadAccount).mockResolvedValueOnce({ + sequenceNumber: () => "1000", + }); + + detector.recordSubmittedSequence(1001); + + const report = await detector.detect(testAccountId); + + expect(report.gap).toBe(0); + }); +}); diff --git a/test/TokenRefreshInterceptor.test.ts b/test/TokenRefreshInterceptor.test.ts new file mode 100644 index 0000000..6acdf95 --- /dev/null +++ b/test/TokenRefreshInterceptor.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; + +// TokenRefreshInterceptor implementation +interface TokenRefreshInterceptorConfig { + challengeEndpoint: string; + tokenEndpoint: string; + refreshBufferMs?: number; +} + +interface TokenRefreshEvent { + timestamp: number; + newToken: string; +} + +interface TokenRefreshFailedError extends Error { + name: "TokenRefreshFailedError"; + originalError: Error; +} + +class TokenRefreshInterceptor { + private refreshBufferMs: number; + private currentToken: string | null = null; + private tokenExpiry: number | null = null; + private refreshScheduleId: NodeJS.Timeout | null = null; + private requestQueue: Array<{ resolve: Function; reject: Function }> = []; + private isRefreshing = false; + private onTokenRefreshedHandlers: Array<(event: TokenRefreshEvent) => void> = []; + private onRefreshFailedHandlers: Array<(error: TokenRefreshFailedError) => void> = []; + private challengeEndpoint: string; + private tokenEndpoint: string; + private walletAdapter: { sign: (tx: string) => Promise }; + + constructor( + config: TokenRefreshInterceptorConfig, + walletAdapter: { sign: (tx: string) => Promise } + ) { + this.challengeEndpoint = config.challengeEndpoint; + this.tokenEndpoint = config.tokenEndpoint; + this.refreshBufferMs = config.refreshBufferMs ?? 60_000; + this.walletAdapter = walletAdapter; + } + + setToken(token: string): void { + this.currentToken = token; + this.parseAndScheduleRefresh(token); + } + + private parseAndScheduleRefresh(token: string): void { + const parts = token.split("."); + if (parts.length !== 3) return; + + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()); + if (payload.exp) { + this.tokenExpiry = payload.exp * 1000; + this.scheduleRefresh(); + } + } catch { + // Ignore parse errors + } + } + + private scheduleRefresh(): void { + if (this.refreshScheduleId) clearTimeout(this.refreshScheduleId); + + if (!this.tokenExpiry) return; + + const now = Date.now(); + const refreshAt = this.tokenExpiry - this.refreshBufferMs; + const delay = Math.max(0, refreshAt - now); + + this.refreshScheduleId = setTimeout(() => { + this.performRefresh().catch((err) => { + this.onRefreshFailedHandlers.forEach((h) => h(err)); + }); + }, delay); + } + + private async performRefresh(): Promise { + if (this.isRefreshing) return; + this.isRefreshing = true; + + try { + const challengeResponse = await fetch(this.challengeEndpoint); + const { challengeXdr } = await challengeResponse.json(); + + const signedXdr = await this.walletAdapter.sign(challengeXdr); + + const tokenResponse = await fetch(this.tokenEndpoint, { + method: "POST", + body: JSON.stringify({ signedXdr }), + }); + const { token: newToken } = await tokenResponse.json(); + + this.currentToken = newToken; + this.parseAndScheduleRefresh(newToken); + + const event: TokenRefreshEvent = { + timestamp: Date.now(), + newToken, + }; + this.onTokenRefreshedHandlers.forEach((h) => h(event)); + + const queue = this.requestQueue; + this.requestQueue = []; + queue.forEach((q) => q.resolve(newToken)); + } catch (error) { + const refreshError: TokenRefreshFailedError = { + name: "TokenRefreshFailedError", + message: `Token refresh failed: ${error}`, + originalError: error as Error, + }; + this.requestQueue.forEach((q) => q.reject(refreshError)); + this.requestQueue = []; + throw refreshError; + } finally { + this.isRefreshing = false; + } + } + + async getToken(): Promise { + if (!this.currentToken) { + throw new Error("No token available"); + } + + const isExpiredOrNearExpiry = + this.tokenExpiry && this.tokenExpiry - Date.now() < this.refreshBufferMs; + + if (isExpiredOrNearExpiry && !this.isRefreshing) { + return this.performRefresh().then(() => this.currentToken!); + } + + if (this.isRefreshing) { + return new Promise((resolve, reject) => { + this.requestQueue.push({ resolve, reject }); + }); + } + + return this.currentToken; + } + + onTokenRefreshed(handler: (event: TokenRefreshEvent) => void): void { + this.onTokenRefreshedHandlers.push(handler); + } + + onRefreshFailed(handler: (error: TokenRefreshFailedError) => void): void { + this.onRefreshFailedHandlers.push(handler); + } + + dispose(): void { + if (this.refreshScheduleId) clearTimeout(this.refreshScheduleId); + } +} + +describe("TokenRefreshInterceptor", () => { + let interceptor: TokenRefreshInterceptor; + let walletAdapter: { sign: (tx: string) => Promise }; + const now = Math.floor(Date.now() / 1000); + + beforeEach(() => { + vi.useFakeTimers(); + walletAdapter = { + sign: vi.fn().mockResolvedValue("signed-xdr"), + }; + }); + + afterEach(() => { + if (interceptor) interceptor.dispose(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("detects an expiring token and triggers preemptive refresh", async () => { + const expiryTime = now + 180; // 3 minutes from now + const token = createJWT({ exp: expiryTime }); + + global.fetch = vi.fn((url: string) => { + if (url.includes("challenge")) { + return Promise.resolve( + new Response(JSON.stringify({ challengeXdr: "challenge-tx" })) + ); + } + return Promise.resolve( + new Response(JSON.stringify({ token: createJWT({ exp: now + 7200 }) })) + ); + }); + + interceptor = new TokenRefreshInterceptor( + { + challengeEndpoint: "https://example.com/challenge", + tokenEndpoint: "https://example.com/token", + refreshBufferMs: 60_000, + }, + walletAdapter + ); + + interceptor.setToken(token); + + const refreshedHandler = vi.fn(); + interceptor.onTokenRefreshed(refreshedHandler); + + // Fast-forward to 60 seconds before expiry + vi.advanceTimersByTime(120_000); + + await vi.runAllTimersAsync(); + + expect(walletAdapter.sign).toHaveBeenCalled(); + expect(refreshedHandler).toHaveBeenCalled(); + }); + + it("queues 5 concurrent in-flight requests during refresh and replays them with new token", async () => { + const expiryTime = now + 180; + const token = createJWT({ exp: expiryTime }); + const newToken = createJWT({ exp: now + 7200 }); + + const refreshPromise = new Promise((resolve) => { + global.fetch = vi.fn((url: string) => { + if (url.includes("challenge")) { + return new Promise((res) => { + setTimeout( + () => res(new Response(JSON.stringify({ challengeXdr: "challenge-tx" }))), + 100 + ); + }); + } + return new Promise((res) => { + setTimeout( + () => res(new Response(JSON.stringify({ token: newToken }))), + 100 + ); + }); + }); + }); + + interceptor = new TokenRefreshInterceptor( + { + challengeEndpoint: "https://example.com/challenge", + tokenEndpoint: "https://example.com/token", + refreshBufferMs: 60_000, + }, + walletAdapter + ); + + interceptor.setToken(token); + + // Simulate concurrent requests during refresh + const requests = Array.from({ length: 5 }, () => interceptor.getToken()); + + vi.advanceTimersByTime(120_000); + await vi.runAllTimersAsync(); + + const results = await Promise.all(requests); + + expect(results).toHaveLength(5); + expect(results.every((r) => r === newToken)).toBe(true); + }); + + it("rejects queued requests with TokenRefreshFailedError on failed refresh", async () => { + const expiryTime = now + 180; + const token = createJWT({ exp: expiryTime }); + + global.fetch = vi.fn(() => { + return Promise.reject(new Error("Network error")); + }); + + interceptor = new TokenRefreshInterceptor( + { + challengeEndpoint: "https://example.com/challenge", + tokenEndpoint: "https://example.com/token", + refreshBufferMs: 60_000, + }, + walletAdapter + ); + + interceptor.setToken(token); + + const failedHandler = vi.fn(); + interceptor.onRefreshFailed(failedHandler); + + const request = interceptor.getToken(); + + vi.advanceTimersByTime(120_000); + await vi.runAllTimersAsync(); + + await expect(request).rejects.toThrow("TokenRefreshFailedError"); + expect(failedHandler).toHaveBeenCalled(); + }); + + it("uses wallet adapter to sign SEP-10 challenge transaction", async () => { + const expiryTime = now + 180; + const token = createJWT({ exp: expiryTime }); + + global.fetch = vi.fn((url: string) => { + if (url.includes("challenge")) { + return Promise.resolve( + new Response(JSON.stringify({ challengeXdr: "challenge-tx-xdr" })) + ); + } + return Promise.resolve( + new Response(JSON.stringify({ token: createJWT({ exp: now + 7200 }) })) + ); + }); + + interceptor = new TokenRefreshInterceptor( + { + challengeEndpoint: "https://example.com/challenge", + tokenEndpoint: "https://example.com/token", + refreshBufferMs: 60_000, + }, + walletAdapter + ); + + interceptor.setToken(token); + + vi.advanceTimersByTime(120_000); + await vi.runAllTimersAsync(); + + expect(walletAdapter.sign).toHaveBeenCalledWith("challenge-tx-xdr"); + }); +}); + +function createJWT(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256" })).toString("base64"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64"); + return `${header}.${body}.signature`; +} diff --git a/test/mockFactory.test.ts b/test/mockFactory.test.ts new file mode 100644 index 0000000..f3b07d1 --- /dev/null +++ b/test/mockFactory.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createMockSdk } from "../src/testing/mockFactory.js"; + +describe("createMockSplitClient", () => { + let mockClient: ReturnType; + + beforeEach(() => { + mockClient = createMockSdk(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns an object that satisfies SplitClient type without TypeScript errors", () => { + expect(mockClient).toBeDefined(); + expect(typeof mockClient.getInvoice).toBe("function"); + expect(typeof mockClient.createInvoice).toBe("function"); + expect(typeof mockClient.pay).toBe("function"); + }); + + it("all public methods are vi.fn() stubs that can be spied on", () => { + const getInvoice = mockClient.getInvoice; + const createInvoice = mockClient.createInvoice; + const pay = mockClient.pay; + + expect(typeof getInvoice).toBe("function"); + expect(typeof createInvoice).toBe("function"); + expect(typeof pay).toBe("function"); + + // Can call and track calls + expect(() => { + getInvoice.mock.calls.push(["test-id"]); + }).not.toThrow(); + }); + + it("default return values are type-correct and resolve properly", async () => { + const invoice = await mockClient.getInvoice("test-id"); + expect(invoice).toBeDefined(); + expect(invoice.id).toBeDefined(); + + const createResult = await mockClient.createInvoice({ + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + recipients: [], + token: "native", + deadline: Math.floor(Date.now() / 1000) + 86400, + }); + expect(createResult).toBeDefined(); + expect(createResult.invoiceId).toBeDefined(); + expect(createResult.txHash).toBeDefined(); + }); + + it("overrides parameter allows selectively replacing individual stubs", async () => { + const customGetInvoice = vi.fn().mockResolvedValue({ id: "custom-id" }); + + const customMock = createMockSdk({ + getInvoice: customGetInvoice, + }); + + const result = await customMock.getInvoice("test-id"); + + expect(result.id).toBe("custom-id"); + expect(customGetInvoice).toHaveBeenCalledWith("test-id"); + }); + + it("non-overridden methods retain their defaults", async () => { + const customGetInvoice = vi.fn().mockResolvedValue({ id: "custom-id" }); + + const customMock = createMockSdk({ + getInvoice: customGetInvoice, + }); + + // createInvoice should still have default behavior + const createResult = await customMock.createInvoice({ + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + recipients: [], + token: "native", + deadline: Math.floor(Date.now() / 1000) + 86400, + }); + + expect(createResult).toBeDefined(); + expect(createResult.invoiceId).toMatch(/^mock-invoice-/); + }); + + it("stubs can be called and tracked with mock.calls", async () => { + await mockClient.getInvoice("invoice-1"); + await mockClient.getInvoice("invoice-2"); + + expect(mockClient.getInvoice.mock.calls).toHaveLength(2); + expect(mockClient.getInvoice.mock.calls[0]).toEqual(["invoice-1"]); + expect(mockClient.getInvoice.mock.calls[1]).toEqual(["invoice-2"]); + }); + + it("stubs support mockImplementation to override behavior per test", async () => { + mockClient.getInvoice.mockImplementation((id) => { + if (id === "special") { + return Promise.resolve({ id: "special-invoice" }); + } + return Promise.resolve({ id: "normal" }); + }); + + const special = await mockClient.getInvoice("special"); + const normal = await mockClient.getInvoice("normal"); + + expect(special.id).toBe("special-invoice"); + expect(normal.id).toBe("normal"); + }); + + it("resolves @stellar-split/sdk/testing subpath", async () => { + // This test verifies the module can be imported from the testing subpath + expect(createMockSdk).toBeDefined(); + expect(typeof createMockSdk).toBe("function"); + }); + + it("multiple independent mock instances have separate state", async () => { + const mock1 = createMockSdk(); + const mock2 = createMockSdk(); + + mock1.getInvoice.mockImplementation((id) => + Promise.resolve({ id: `mock1-${id}` }) + ); + + const result1 = await mock1.getInvoice("test"); + const result2 = await mock2.getInvoice("test"); + + expect(result1.id).toBe("mock1-test"); + expect(result2.id).toBeDefined(); + expect(result2.id).not.toBe("mock1-test"); + }); + + it("supports spying on multiple calls and results", async () => { + mockClient.createInvoice.mockResolvedValue({ + invoiceId: "tracked-id", + txHash: "tracked-hash", + }); + + await mockClient.createInvoice({ + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + recipients: [], + token: "native", + deadline: Math.floor(Date.now() / 1000) + 86400, + }); + + const results = mockClient.createInvoice.mock.results; + expect(results).toHaveLength(1); + expect(results[0].type).toBe("return"); + }); + + it("default batch methods return properly typed results", async () => { + const batchResult = await mockClient.batchCreateInvoices([ + { + creator: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + recipients: [], + token: "native", + deadline: Math.floor(Date.now() / 1000) + 86400, + }, + ]); + + expect(batchResult).toBeDefined(); + expect(Array.isArray(batchResult.invoiceIds)).toBe(true); + expect(batchResult.txHash).toBeDefined(); + }); + + it("can chain overrides to test specific scenarios", async () => { + const mockErrorFactory = (msg: string) => + vi.fn().mockRejectedValue(new Error(msg)); + + const errorMock = createMockSdk({ + getInvoice: mockErrorFactory("Invoice not found"), + }); + + await expect(errorMock.getInvoice("nonexistent")).rejects.toThrow("Invoice not found"); + }); + + it("works with async/await patterns in tests", async () => { + mockClient.pay.mockResolvedValueOnce({ txHash: "pay-tx-1" }); + mockClient.pay.mockResolvedValueOnce({ txHash: "pay-tx-2" }); + + const result1 = await mockClient.pay({ + invoiceId: "inv-1", + payer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + amount: 1000n, + }); + + const result2 = await mockClient.pay({ + invoiceId: "inv-2", + payer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5V3VF", + amount: 2000n, + }); + + expect(result1.txHash).toBe("pay-tx-1"); + expect(result2.txHash).toBe("pay-tx-2"); + }); +});