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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/sdk/tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
126 changes: 126 additions & 0 deletions packages/sdk/tests/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<typeof vi.fn>) {
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);
});
});
77 changes: 77 additions & 0 deletions packages/sdk/tests/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
66 changes: 66 additions & 0 deletions packages/sdk/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 7 additions & 0 deletions packages/sdk/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
},
});
Loading