From 25ce51bf9aac4f7234e9d417868a197a6d8f04c0 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Wed, 25 Feb 2026 14:42:12 -0500 Subject: [PATCH] feat: add error handling, validation, timeout, and verbose mode - Handle network errors gracefully in CLI with structured JSON on stderr - Add NaN-guarding parseIntOption/parseFloatOption helpers; replace all raw parseInt/parseFloat calls across 8 command files - Fix empty object edge case in table formatter (Math.max on empty array) - Add configurable fetch timeout (default 30s) with --timeout CLI option - Add --verbose flag for request/response debugging on stderr - Extend post() to accept optional body and query params - Document cursor vs next parameter mapping in tokens API Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 21 ++++- src/client.ts | 65 +++++++++++++-- src/commands/collections.ts | 3 +- src/commands/events.ts | 15 ++-- src/commands/listings.ts | 5 +- src/commands/nfts.ts | 7 +- src/commands/offers.ts | 7 +- src/commands/search.ts | 9 +- src/commands/swaps.ts | 3 +- src/commands/tokens.ts | 7 +- src/output.ts | 1 + src/parse.ts | 15 ++++ src/sdk.ts | 4 + src/types/index.ts | 2 + test/client.test.ts | 162 +++++++++++++++++++++++++++++++----- test/output.test.ts | 4 + test/parse.test.ts | 42 ++++++++++ 17 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 src/parse.ts create mode 100644 test/parse.test.ts diff --git a/src/cli.ts b/src/cli.ts index 73c59b4..ad2c448 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { swapsCommand, tokensCommand, } from "./commands/index.js" +import { parseIntOption } from "./parse.js" const BANNER = ` ____ _____ @@ -34,12 +35,16 @@ program .option("--chain ", "Default chain", "ethereum") .option("--format ", "Output format (json or table)", "json") .option("--base-url ", "API base URL") + .option("--timeout ", "Request timeout in milliseconds", "30000") + .option("--verbose", "Log request and response info to stderr") function getClient(): OpenSeaClient { const opts = program.opts<{ apiKey?: string chain: string baseUrl?: string + timeout: string + verbose?: boolean }>() const apiKey = opts.apiKey ?? process.env.OPENSEA_API_KEY @@ -54,6 +59,8 @@ function getClient(): OpenSeaClient { apiKey, chain: opts.chain, baseUrl: opts.baseUrl, + timeout: parseIntOption(opts.timeout, "--timeout"), + verbose: opts.verbose, }) } @@ -91,7 +98,19 @@ async function main() { ) process.exit(1) } - throw error + const label = + error instanceof TypeError ? "Network Error" : (error as Error).name + console.error( + JSON.stringify( + { + error: label, + message: (error as Error).message, + }, + null, + 2, + ), + ) + process.exit(1) } } diff --git a/src/client.ts b/src/client.ts index 3accc83..c48c49b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,18 +2,23 @@ import type { OpenSeaClientConfig } from "./types/index.js" const DEFAULT_BASE_URL = "https://api.opensea.io" const DEFAULT_GRAPHQL_URL = "https://gql.opensea.io/graphql" +const DEFAULT_TIMEOUT_MS = 30_000 export class OpenSeaClient { private apiKey: string private baseUrl: string private graphqlUrl: string private defaultChain: string + private timeoutMs: number + private verbose: boolean constructor(config: OpenSeaClientConfig) { this.apiKey = config.apiKey this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL this.graphqlUrl = config.graphqlUrl ?? DEFAULT_GRAPHQL_URL this.defaultChain = config.chain ?? "ethereum" + this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS + this.verbose = config.verbose ?? false } async get(path: string, params?: Record): Promise { @@ -27,14 +32,23 @@ export class OpenSeaClient { } } + if (this.verbose) { + console.error(`[verbose] GET ${url.toString()}`) + } + const response = await fetch(url.toString(), { method: "GET", headers: { Accept: "application/json", "x-api-key": this.apiKey, }, + signal: AbortSignal.timeout(this.timeoutMs), }) + if (this.verbose) { + console.error(`[verbose] ${response.status} ${response.statusText}`) + } + if (!response.ok) { const body = await response.text() throw new OpenSeaAPIError(response.status, body, path) @@ -43,20 +57,48 @@ export class OpenSeaClient { return response.json() as Promise } - async post(path: string): Promise { + async post( + path: string, + body?: Record, + params?: Record, + ): Promise { const url = new URL(`${this.baseUrl}${path}`) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)) + } + } + } + + const headers: Record = { + Accept: "application/json", + "x-api-key": this.apiKey, + } + + if (body) { + headers["Content-Type"] = "application/json" + } + + if (this.verbose) { + console.error(`[verbose] POST ${url.toString()}`) + } + const response = await fetch(url.toString(), { method: "POST", - headers: { - Accept: "application/json", - "x-api-key": this.apiKey, - }, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(this.timeoutMs), }) + if (this.verbose) { + console.error(`[verbose] ${response.status} ${response.statusText}`) + } + if (!response.ok) { - const body = await response.text() - throw new OpenSeaAPIError(response.status, body, path) + const text = await response.text() + throw new OpenSeaAPIError(response.status, text, path) } return response.json() as Promise @@ -66,6 +108,10 @@ export class OpenSeaClient { query: string, variables?: Record, ): Promise { + if (this.verbose) { + console.error(`[verbose] POST ${this.graphqlUrl}`) + } + const response = await fetch(this.graphqlUrl, { method: "POST", headers: { @@ -74,8 +120,13 @@ export class OpenSeaClient { "x-api-key": this.apiKey, }, body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(this.timeoutMs), }) + if (this.verbose) { + console.error(`[verbose] ${response.status} ${response.statusText}`) + } + if (!response.ok) { const body = await response.text() throw new OpenSeaAPIError(response.status, body, "graphql") diff --git a/src/commands/collections.ts b/src/commands/collections.ts index 2c05331..ff9fa0c 100644 --- a/src/commands/collections.ts +++ b/src/commands/collections.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { Chain, Collection, @@ -57,7 +58,7 @@ export function collectionsCommand( order_by: options.orderBy as CollectionOrderBy | undefined, creator_username: options.creator, include_hidden: options.includeHidden, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) diff --git a/src/commands/events.ts b/src/commands/events.ts index c565226..7fd39c2 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { AssetEvent } from "../types/index.js" export function eventsCommand( @@ -36,12 +37,14 @@ export function eventsCommand( next?: string }>("/api/v2/events", { event_type: options.eventType, - after: options.after ? Number.parseInt(options.after, 10) : undefined, + after: options.after + ? parseIntOption(options.after, "--after") + : undefined, before: options.before - ? Number.parseInt(options.before, 10) + ? parseIntOption(options.before, "--before") : undefined, chain: options.chain, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -73,7 +76,7 @@ export function eventsCommand( }>(`/api/v2/events/accounts/${address}`, { event_type: options.eventType, chain: options.chain, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -102,7 +105,7 @@ export function eventsCommand( next?: string }>(`/api/v2/events/collection/${slug}`, { event_type: options.eventType, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -137,7 +140,7 @@ export function eventsCommand( `/api/v2/events/chain/${chain}/contract/${contract}/nfts/${tokenId}`, { event_type: options.eventType, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }, ) diff --git a/src/commands/listings.ts b/src/commands/listings.ts index 6d158b7..c4ee0a5 100644 --- a/src/commands/listings.ts +++ b/src/commands/listings.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { Listing } from "../types/index.js" export function listingsCommand( @@ -22,7 +23,7 @@ export function listingsCommand( listings: Listing[] next?: string }>(`/api/v2/listings/collection/${collection}/all`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -42,7 +43,7 @@ export function listingsCommand( listings: Listing[] next?: string }>(`/api/v2/listings/collection/${collection}/best`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) diff --git a/src/commands/nfts.ts b/src/commands/nfts.ts index ec15037..26af0a1 100644 --- a/src/commands/nfts.ts +++ b/src/commands/nfts.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { Contract, NFT } from "../types/index.js" export function nftsCommand( @@ -34,7 +35,7 @@ export function nftsCommand( const result = await client.get<{ nfts: NFT[]; next?: string }>( `/api/v2/collection/${slug}/nfts`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }, ) @@ -58,7 +59,7 @@ export function nftsCommand( const result = await client.get<{ nfts: NFT[]; next?: string }>( `/api/v2/chain/${chain}/contract/${contract}/nfts`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }, ) @@ -83,7 +84,7 @@ export function nftsCommand( const result = await client.get<{ nfts: NFT[]; next?: string }>( `/api/v2/chain/${chain}/account/${address}/nfts`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }, ) diff --git a/src/commands/offers.ts b/src/commands/offers.ts index eb33e4b..5dc31e5 100644 --- a/src/commands/offers.ts +++ b/src/commands/offers.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { Offer } from "../types/index.js" export function offersCommand( @@ -22,7 +23,7 @@ export function offersCommand( offers: Offer[] next?: string }>(`/api/v2/offers/collection/${collection}/all`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -42,7 +43,7 @@ export function offersCommand( offers: Offer[] next?: string }>(`/api/v2/offers/collection/${collection}`, { - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) @@ -87,7 +88,7 @@ export function offersCommand( }>(`/api/v2/offers/collection/${collection}/traits`, { type: options.type, value: options.value, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), next: options.next, }) console.log(formatOutput(result, getFormat())) diff --git a/src/commands/search.ts b/src/commands/search.ts index db2abe4..621eb8e 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import { SEARCH_ACCOUNTS_QUERY, SEARCH_COLLECTIONS_QUERY, @@ -35,7 +36,7 @@ export function searchCommand( collectionsByQuery: SearchCollectionResult[] }>(SEARCH_COLLECTIONS_QUERY, { query, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), chains: options.chains?.split(","), }) console.log(formatOutput(result.collectionsByQuery, getFormat())) @@ -60,7 +61,7 @@ export function searchCommand( }>(SEARCH_NFTS_QUERY, { query, collectionSlug: options.collection, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), chains: options.chains?.split(","), }) console.log(formatOutput(result.itemsByQuery, getFormat())) @@ -80,7 +81,7 @@ export function searchCommand( currenciesByQuery: SearchTokenResult[] }>(SEARCH_TOKENS_QUERY, { query, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), chain: options.chain, }) console.log(formatOutput(result.currenciesByQuery, getFormat())) @@ -98,7 +99,7 @@ export function searchCommand( accountsByQuery: SearchAccountResult[] }>(SEARCH_ACCOUNTS_QUERY, { query, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), }) console.log(formatOutput(result.accountsByQuery, getFormat())) }) diff --git a/src/commands/swaps.ts b/src/commands/swaps.ts index 6fe910f..a4fc712 100644 --- a/src/commands/swaps.ts +++ b/src/commands/swaps.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseFloatOption } from "../parse.js" import type { SwapQuoteResponse } from "../types/index.js" export function swapsCommand( @@ -58,7 +59,7 @@ export function swapsCommand( quantity: options.quantity, address: options.address, slippage: options.slippage - ? Number.parseFloat(options.slippage) + ? parseFloatOption(options.slippage, "--slippage") : undefined, recipient: options.recipient, }, diff --git a/src/commands/tokens.ts b/src/commands/tokens.ts index 0679c31..e18457e 100644 --- a/src/commands/tokens.ts +++ b/src/commands/tokens.ts @@ -1,6 +1,7 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" import { formatOutput } from "../output.js" +import { parseIntOption } from "../parse.js" import type { Chain, Token, TokenDetails } from "../types/index.js" export function tokensCommand( @@ -24,7 +25,8 @@ export function tokensCommand( "/api/v2/tokens/trending", { chains: options.chains, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), + // Tokens API uses "cursor" instead of "next" as the query param cursor: options.next, }, ) @@ -45,7 +47,8 @@ export function tokensCommand( "/api/v2/tokens/top", { chains: options.chains, - limit: Number.parseInt(options.limit, 10), + limit: parseIntOption(options.limit, "--limit"), + // Tokens API uses "cursor" instead of "next" as the query param cursor: options.next, }, ) diff --git a/src/output.ts b/src/output.ts index dfaac95..c6530f6 100644 --- a/src/output.ts +++ b/src/output.ts @@ -35,6 +35,7 @@ function formatTable(data: unknown): string { if (data && typeof data === "object") { const entries = Object.entries(data as Record) + if (entries.length === 0) return "(empty)" const maxKeyLength = Math.max(...entries.map(([k]) => k.length)) return entries .map(([key, value]) => { diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..12a1bdc --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,15 @@ +export function parseIntOption(value: string, name: string): number { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + throw new Error(`Invalid value for ${name}: "${value}" is not an integer`) + } + return parsed +} + +export function parseFloatOption(value: string, name: string): number { + const parsed = Number.parseFloat(value) + if (Number.isNaN(parsed)) { + throw new Error(`Invalid value for ${name}: "${value}" is not a number`) + } + return parsed +} diff --git a/src/sdk.ts b/src/sdk.ts index 1d3539d..6819fc7 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -319,6 +319,8 @@ class TokensAPI { chains?: string[] next?: string }): Promise<{ tokens: Token[]; next?: string }> { + // The tokens API uses "cursor" as its query param instead of "next". + // The SDK accepts "next" for consistency with all other endpoints. return this.client.get("/api/v2/tokens/trending", { limit: options?.limit, chains: options?.chains?.join(","), @@ -331,6 +333,8 @@ class TokensAPI { chains?: string[] next?: string }): Promise<{ tokens: Token[]; next?: string }> { + // The tokens API uses "cursor" as its query param instead of "next". + // The SDK accepts "next" for consistency with all other endpoints. return this.client.get("/api/v2/tokens/top", { limit: options?.limit, chains: options?.chains?.join(","), diff --git a/src/types/index.ts b/src/types/index.ts index 0d83959..a2be4b2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,8 @@ export interface OpenSeaClientConfig { baseUrl?: string graphqlUrl?: string chain?: string + timeout?: number + verbose?: boolean } export interface CommandOptions { diff --git a/test/client.test.ts b/test/client.test.ts index 4f8bbe0..79ac428 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -35,13 +35,16 @@ describe("OpenSeaClient", () => { const result = await client.get("/api/v2/test") - expect(fetch).toHaveBeenCalledWith("https://api.opensea.io/api/v2/test", { - method: "GET", - headers: { - Accept: "application/json", - "x-api-key": "test-key", - }, - }) + expect(fetch).toHaveBeenCalledWith( + "https://api.opensea.io/api/v2/test", + expect.objectContaining({ + method: "GET", + headers: { + Accept: "application/json", + "x-api-key": "test-key", + }, + }), + ) expect(result).toEqual(mockResponse) }) @@ -88,17 +91,48 @@ describe("OpenSeaClient", () => { expect(fetch).toHaveBeenCalledWith( "https://api.opensea.io/api/v2/refresh", - { + expect.objectContaining({ method: "POST", headers: { Accept: "application/json", "x-api-key": "test-key", }, - }, + }), ) expect(result).toEqual(mockResponse) }) + it("sends JSON body when provided", async () => { + mockFetchResponse({ id: 1 }) + + await client.post("/api/v2/create", { name: "test" }) + + expect(fetch).toHaveBeenCalledWith( + "https://api.opensea.io/api/v2/create", + expect.objectContaining({ + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ name: "test" }), + }), + ) + }) + + it("appends query params when provided", async () => { + mockFetchResponse({}) + + await client.post("/api/v2/action", undefined, { + chain: "ethereum", + }) + + const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string + const url = new URL(calledUrl) + expect(url.searchParams.get("chain")).toBe("ethereum") + }) + it("throws OpenSeaAPIError on non-ok response", async () => { mockFetchTextResponse("Server Error", 500) @@ -116,18 +150,21 @@ describe("OpenSeaClient", () => { { query: "test" }, ) - expect(fetch).toHaveBeenCalledWith("https://gql.opensea.io/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "x-api-key": "test-key", - }, - body: JSON.stringify({ - query: "query { collectionsByQuery { slug } }", - variables: { query: "test" }, + expect(fetch).toHaveBeenCalledWith( + "https://gql.opensea.io/graphql", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-api-key": "test-key", + }, + body: JSON.stringify({ + query: "query { collectionsByQuery { slug } }", + variables: { query: "test" }, + }), }), - }) + ) expect(result).toEqual(mockData) }) @@ -184,6 +221,91 @@ describe("OpenSeaClient", () => { }) }) + describe("timeout", () => { + it("passes AbortSignal.timeout to fetch calls", async () => { + const timedClient = new OpenSeaClient({ + apiKey: "test-key", + timeout: 5000, + }) + mockFetchResponse({ ok: true }) + + await timedClient.get("/api/v2/test") + + const fetchOptions = vi.mocked(fetch).mock.calls[0][1] as RequestInit + expect(fetchOptions.signal).toBeInstanceOf(AbortSignal) + }) + + it("uses default 30s timeout", async () => { + mockFetchResponse({ ok: true }) + + await client.get("/api/v2/test") + + const fetchOptions = vi.mocked(fetch).mock.calls[0][1] as RequestInit + expect(fetchOptions.signal).toBeInstanceOf(AbortSignal) + }) + }) + + describe("verbose", () => { + it("logs request and response to stderr when enabled", async () => { + const verboseClient = new OpenSeaClient({ + apiKey: "test-key", + verbose: true, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchResponse({ name: "test" }) + + await verboseClient.get("/api/v2/collections/test") + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + "[verbose] GET https://api.opensea.io/api/v2/collections/test", + ), + ) + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[verbose] 200"), + ) + }) + + it("does not log when verbose is disabled", async () => { + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchResponse({ name: "test" }) + + await client.get("/api/v2/test") + + expect(stderrSpy).not.toHaveBeenCalled() + }) + + it("logs for post requests", async () => { + const verboseClient = new OpenSeaClient({ + apiKey: "test-key", + verbose: true, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchResponse({ status: "ok" }) + + await verboseClient.post("/api/v2/refresh") + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[verbose] POST"), + ) + }) + + it("logs for graphql requests", async () => { + const verboseClient = new OpenSeaClient({ + apiKey: "test-key", + verbose: true, + }) + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchResponse({ data: { test: true } }) + + await verboseClient.graphql("query { test }") + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("[verbose] POST"), + ) + }) + }) + describe("getDefaultChain", () => { it("returns the default chain", () => { expect(client.getDefaultChain()).toBe("ethereum") diff --git a/test/output.test.ts b/test/output.test.ts index f7638fe..765f655 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -34,6 +34,10 @@ describe("formatOutput", () => { expect(formatOutput([], "table")).toBe("(empty)") }) + it("returns (empty) for empty object", () => { + expect(formatOutput({}, "table")).toBe("(empty)") + }) + it("formats an object as key-value pairs", () => { const data = { name: "test", count: 5 } const result = formatOutput(data, "table") diff --git a/test/parse.test.ts b/test/parse.test.ts new file mode 100644 index 0000000..3fa6dec --- /dev/null +++ b/test/parse.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest" +import { parseFloatOption, parseIntOption } from "../src/parse.js" + +describe("parseIntOption", () => { + it("parses valid integers", () => { + expect(parseIntOption("42", "--limit")).toBe(42) + expect(parseIntOption("0", "--limit")).toBe(0) + expect(parseIntOption("-1", "--offset")).toBe(-1) + }) + + it("throws on non-numeric strings", () => { + expect(() => parseIntOption("abc", "--limit")).toThrow( + 'Invalid value for --limit: "abc" is not an integer', + ) + }) + + it("throws on empty string", () => { + expect(() => parseIntOption("", "--limit")).toThrow( + 'Invalid value for --limit: "" is not an integer', + ) + }) +}) + +describe("parseFloatOption", () => { + it("parses valid floats", () => { + expect(parseFloatOption("0.5", "--slippage")).toBe(0.5) + expect(parseFloatOption("1", "--slippage")).toBe(1) + expect(parseFloatOption("0.01", "--slippage")).toBe(0.01) + }) + + it("throws on non-numeric strings", () => { + expect(() => parseFloatOption("abc", "--slippage")).toThrow( + 'Invalid value for --slippage: "abc" is not a number', + ) + }) + + it("throws on empty string", () => { + expect(() => parseFloatOption("", "--slippage")).toThrow( + 'Invalid value for --slippage: "" is not a number', + ) + }) +})