From 4bfc833ff45684d8190d6ed9bbb2726fa1e0a1a2 Mon Sep 17 00:00:00 2001 From: temiport25 Date: Sun, 29 Mar 2026 20:22:53 +0000 Subject: [PATCH 1/2] sdk: set up Vitest and add unit tests for validation utils (#194) --- packages/sdk/tests/utils.test.ts | 66 ++++++++++++++++++++++++++++++++ packages/sdk/vitest.config.ts | 7 ++++ 2 files changed, 73 insertions(+) create mode 100644 packages/sdk/tests/utils.test.ts create mode 100644 packages/sdk/vitest.config.ts diff --git a/packages/sdk/tests/utils.test.ts b/packages/sdk/tests/utils.test.ts new file mode 100644 index 0000000..675cb9e --- /dev/null +++ b/packages/sdk/tests/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { validateTransactionHash, validateAccountAddress, buildUrl } from "../src/utils/index.js"; +import { InvalidInputError } from "../src/errors/index.js"; + +describe("validateTransactionHash", () => { + const valid64Hex = "a".repeat(64); + + it("accepts a valid 64-char lowercase hex hash", () => { + expect(() => validateTransactionHash(valid64Hex)).not.toThrow(); + }); + + it("accepts a valid 64-char uppercase hex hash", () => { + expect(() => validateTransactionHash("A".repeat(64))).not.toThrow(); + }); + + it("throws InvalidInputError for a 63-char hash", () => { + expect(() => validateTransactionHash("a".repeat(63))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError for a 65-char hash", () => { + expect(() => validateTransactionHash("a".repeat(65))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError for non-hex characters", () => { + expect(() => validateTransactionHash("z".repeat(64))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError for an empty string", () => { + expect(() => validateTransactionHash("")).toThrow(InvalidInputError); + }); +}); + +describe("validateAccountAddress", () => { + // A valid G-address: G + 55 uppercase base-32 chars (A-Z, 2-7) + const validAddress = "G" + "A".repeat(55); + + it("accepts a valid G-address", () => { + expect(() => validateAccountAddress(validAddress)).not.toThrow(); + }); + + it("throws InvalidInputError when address starts with wrong letter", () => { + expect(() => validateAccountAddress("A" + "A".repeat(55))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError when address is too short", () => { + expect(() => validateAccountAddress("G" + "A".repeat(54))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError when address is too long", () => { + expect(() => validateAccountAddress("G" + "A".repeat(56))).toThrow(InvalidInputError); + }); + + it("throws InvalidInputError for a lowercase address", () => { + expect(() => validateAccountAddress("g" + "a".repeat(55))).toThrow(InvalidInputError); + }); +}); + +describe("buildUrl", () => { + it("removes trailing slash from base before joining", () => { + expect(buildUrl("https://example.com/", "/api/tx")).toBe("https://example.com/api/tx"); + }); + + it("joins base without trailing slash and path correctly", () => { + expect(buildUrl("https://example.com", "/api/tx")).toBe("https://example.com/api/tx"); + }); +}); diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); From 1eb563da988903e995ae018d1d041580563f1e46 Mon Sep 17 00:00:00 2001 From: temiport25 Date: Sun, 29 Mar 2026 20:26:53 +0000 Subject: [PATCH 2/2] sdk: add unit tests for errors, MemoryCache, and StellarExplainClient (#195 #196 #197) --- packages/sdk/tests/cache.test.ts | 55 +++++++++++++ packages/sdk/tests/client.test.ts | 126 ++++++++++++++++++++++++++++++ packages/sdk/tests/errors.test.ts | 77 ++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 packages/sdk/tests/cache.test.ts create mode 100644 packages/sdk/tests/client.test.ts create mode 100644 packages/sdk/tests/errors.test.ts diff --git a/packages/sdk/tests/cache.test.ts b/packages/sdk/tests/cache.test.ts new file mode 100644 index 0000000..61e6f4f --- /dev/null +++ b/packages/sdk/tests/cache.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { MemoryCache } from "../src/cache/MemoryCache.js"; + +describe("MemoryCache", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("set then get within TTL returns value", () => { + const cache = new MemoryCache(1000); + cache.set("k", "v"); + expect(cache.get("k")).toBe("v"); + }); + + it("get after TTL expires returns null", () => { + const cache = new MemoryCache(1000); + cache.set("k", "v"); + vi.advanceTimersByTime(1001); + expect(cache.get("k")).toBeNull(); + }); + + it("set with ttl=0 makes get immediately return null", () => { + const cache = new MemoryCache(0); + cache.set("k", "v"); + expect(cache.get("k")).toBeNull(); + }); + + it("set with ttlOverride overrides instance TTL", () => { + const cache = new MemoryCache(1000); + cache.set("k", "v", 5000); + vi.advanceTimersByTime(2000); + expect(cache.get("k")).toBe("v"); + }); + + it("delete removes a key", () => { + const cache = new MemoryCache(1000); + cache.set("k", "v"); + cache.delete("k"); + expect(cache.get("k")).toBeNull(); + }); + + it("clear empties the store and size becomes 0", () => { + const cache = new MemoryCache(1000); + cache.set("a", 1); + cache.set("b", 2); + cache.clear(); + expect(cache.size).toBe(0); + }); + + it("size reflects current number of entries", () => { + const cache = new MemoryCache(1000); + cache.set("a", 1); + cache.set("b", 2); + expect(cache.size).toBe(2); + }); +}); diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts new file mode 100644 index 0000000..537a2a5 --- /dev/null +++ b/packages/sdk/tests/client.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi } from "vitest"; +import { StellarExplainClient } from "../src/client/StellarExplainClient.js"; +import { + InvalidInputError, + NotFoundError, + RateLimitError, + UpstreamError, + TimeoutError, +} from "../src/errors/index.js"; +import type { + TransactionExplanation, + AccountExplanation, + HealthResponse, +} from "../src/types/index.js"; + +const VALID_HASH = "a".repeat(64); +const VALID_ADDRESS = "G" + "A".repeat(55); + +function mockFetch(status: number, body: unknown, headers: Record = {}) { + return vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }) + ) + ); +} + +const TX: TransactionExplanation = { + hash: VALID_HASH, + summary: "Payment of 10 XLM", + status: "success", + ledger: 1, + created_at: "2024-01-01T00:00:00Z", + fee_charged: "100", + memo: null, + payments: [], + skipped_operations: 0, +}; + +const ACCOUNT: AccountExplanation = { + account_id: VALID_ADDRESS, + summary: "Account summary", + last_modified_ledger: 1, + subentry_count: 0, + balances: [], + signers: [], +}; + +const HEALTH: HealthResponse = { status: "ok", horizon_reachable: true, version: "1.0.0" }; + +const ERROR_BODY = { error: { code: "ERR", message: "error" } }; + +function makeClient(fetchImpl: ReturnType) { + return new StellarExplainClient({ baseUrl: "https://example.com", fetchImpl }); +} + +describe("StellarExplainClient", () => { + it("explainTransaction with valid hash returns TransactionExplanation", async () => { + const client = makeClient(mockFetch(200, TX)); + expect(await client.explainTransaction(VALID_HASH)).toEqual(TX); + }); + + it("explainTransaction with invalid hash throws InvalidInputError before fetch", async () => { + const fetch = mockFetch(200, TX); + const client = makeClient(fetch); + await expect(client.explainTransaction("bad")).rejects.toBeInstanceOf(InvalidInputError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("explainTransaction on 404 throws NotFoundError", async () => { + const client = makeClient(mockFetch(404, ERROR_BODY)); + await expect(client.explainTransaction(VALID_HASH)).rejects.toBeInstanceOf(NotFoundError); + }); + + it("explainTransaction on 429 throws RateLimitError", async () => { + const client = makeClient(mockFetch(429, ERROR_BODY)); + await expect(client.explainTransaction(VALID_HASH)).rejects.toBeInstanceOf(RateLimitError); + }); + + it("explainTransaction on 500 throws UpstreamError", async () => { + const client = makeClient(mockFetch(500, ERROR_BODY)); + await expect(client.explainTransaction(VALID_HASH)).rejects.toBeInstanceOf(UpstreamError); + }); + + it("explainTransaction with AbortError throws TimeoutError", async () => { + const fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError")); + const client = makeClient(fetch); + await expect(client.explainTransaction(VALID_HASH)).rejects.toBeInstanceOf(TimeoutError); + }); + + it("second call with same hash uses cache — fetch called only once", async () => { + const fetch = mockFetch(200, TX); + const client = makeClient(fetch); + await client.explainTransaction(VALID_HASH); + await client.explainTransaction(VALID_HASH); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("clearCache then same call fetches again", async () => { + const fetch = mockFetch(200, TX); + const client = makeClient(fetch); + await client.explainTransaction(VALID_HASH); + client.clearCache(); + await client.explainTransaction(VALID_HASH); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("explainAccount with valid address returns AccountExplanation", async () => { + const client = makeClient(mockFetch(200, ACCOUNT)); + expect(await client.explainAccount(VALID_ADDRESS)).toEqual(ACCOUNT); + }); + + it("explainAccount with invalid address throws InvalidInputError", async () => { + const fetch = mockFetch(200, ACCOUNT); + const client = makeClient(fetch); + await expect(client.explainAccount("bad")).rejects.toBeInstanceOf(InvalidInputError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("health returns HealthResponse", async () => { + const client = makeClient(mockFetch(200, HEALTH)); + expect(await client.health()).toEqual(HEALTH); + }); +}); diff --git a/packages/sdk/tests/errors.test.ts b/packages/sdk/tests/errors.test.ts new file mode 100644 index 0000000..443fb89 --- /dev/null +++ b/packages/sdk/tests/errors.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { + StellarExplainError, + NotFoundError, + RateLimitError, + TimeoutError, + NetworkError, + UpstreamError, + InvalidInputError, +} from "../src/errors/index.js"; + +const errorClasses = [ + NotFoundError, + RateLimitError, + TimeoutError, + NetworkError, + UpstreamError, + InvalidInputError, +] as const; + +describe("error class hierarchy", () => { + it.each(errorClasses)("%s is instanceof its own class", (Cls) => { + expect(new Cls()).toBeInstanceOf(Cls); + }); + + it.each(errorClasses)("%s is instanceof StellarExplainError", (Cls) => { + expect(new Cls()).toBeInstanceOf(StellarExplainError); + }); + + it.each(errorClasses)("%s is instanceof Error", (Cls) => { + expect(new Cls()).toBeInstanceOf(Error); + }); + + it.each(errorClasses)("%s preserves message", (Cls) => { + expect(new Cls("test message").message).toBe("test message"); + }); + + it("NotFoundError has statusCode 404", () => { + expect(new NotFoundError().statusCode).toBe(404); + }); + + it("RateLimitError stores retryAfter when provided", () => { + expect(new RateLimitError("msg", 30).retryAfter).toBe(30); + }); + + it("RateLimitError has retryAfter undefined when not provided", () => { + expect(new RateLimitError().retryAfter).toBeUndefined(); + }); + + it("TimeoutError has code TIMEOUT", () => { + expect(new TimeoutError().code).toBe("TIMEOUT"); + }); + + it("NotFoundError has name NotFoundError", () => { + expect(new NotFoundError().name).toBe("NotFoundError"); + }); + + it("RateLimitError has name RateLimitError", () => { + expect(new RateLimitError().name).toBe("RateLimitError"); + }); + + it("TimeoutError has name TimeoutError", () => { + expect(new TimeoutError().name).toBe("TimeoutError"); + }); + + it("NetworkError has name NetworkError", () => { + expect(new NetworkError().name).toBe("NetworkError"); + }); + + it("UpstreamError has name UpstreamError", () => { + expect(new UpstreamError().name).toBe("UpstreamError"); + }); + + it("InvalidInputError has name InvalidInputError", () => { + expect(new InvalidInputError().name).toBe("InvalidInputError"); + }); +});