diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c4f6353..d6d0878 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -64,12 +64,11 @@ Event types: `sale`, `transfer`, `mint`, `listing`, `offer`, `trait_offer`, `col ## Search ```bash -opensea search collections [--chains ] [--limit ] -opensea search nfts [--collection ] [--chains ] [--limit ] -opensea search tokens [--chain ] [--limit ] -opensea search accounts [--limit ] +opensea search [--types ] [--chains ] [--limit ] ``` +`--types` values (comma-separated): `collection`, `nft`, `token`, `account` + ## Tokens ```bash @@ -90,4 +89,4 @@ opensea swaps quote --from-chain --from-address
--to-chain ``` -> REST list commands support cursor-based pagination. Search commands return a flat list with no cursor. See [pagination.md](pagination.md) for details. +> REST list commands support cursor-based pagination. The search command returns a flat list with no cursor. See [pagination.md](pagination.md) for details. diff --git a/docs/examples.md b/docs/examples.md index 72710c4..0c4963c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -88,23 +88,23 @@ opensea events by-account 0x21130e908bba2d41b63fbca7caa131285b8724f8 --limit 2 ## Search ```bash -# Search for collections -opensea search collections mfers +# Search across all types (defaults to collections and tokens) +opensea search mfers -# Search for NFTs -opensea search nfts "cool cat" --limit 5 +# Search for collections only +opensea search "bored ape" --types collection -# Search for NFTs within a specific collection -opensea search nfts "rare" --collection tiny-dinos-eth --limit 5 - -# Search for tokens/currencies -opensea search tokens eth --limit 5 +# Search for NFTs and collections +opensea search "cool cat" --types collection,nft --limit 5 # Search for tokens on a specific chain -opensea search tokens usdc --chain base --limit 5 +opensea search usdc --types token --chains base --limit 5 # Search for accounts -opensea search accounts vitalik --limit 5 +opensea search vitalik --types account --limit 5 + +# Search across all types on a specific chain +opensea search "ape" --types collection,nft,token,account --chains ethereum ``` ## Tokens diff --git a/docs/pagination.md b/docs/pagination.md index fcda673..3967e0f 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -36,7 +36,7 @@ opensea tokens trending --limit 5 --next "abc123..." - `tokens trending` - `tokens top` -> **Note:** Search commands (`search collections`, `search nfts`, `search tokens`, `search accounts`) do not support cursor-based pagination. The underlying GraphQL API returns a flat list with no `next` cursor. +> **Note:** The `search` command does not support cursor-based pagination. The search API returns a flat list with no `next` cursor; use `--limit` to control result count (max 50). ## SDK diff --git a/docs/sdk.md b/docs/sdk.md index d7f9ca9..5100976 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -16,7 +16,6 @@ const client = new OpenSeaCLI({ apiKey: process.env.OPENSEA_API_KEY }) |---|---|---|---| | `apiKey` | `string` | *required* | OpenSea API key | | `baseUrl` | `string` | `https://api.opensea.io` | API base URL override | -| `graphqlUrl` | `string` | `https://gql.opensea.io/graphql` | GraphQL URL override | | `chain` | `string` | `"ethereum"` | Default chain | ## Collections @@ -142,26 +141,32 @@ const tokenDetails = await client.tokens.get("base", "0x123...") ## Search -Search methods use GraphQL and return different result shapes than the REST API. Search endpoints do not currently expose a `next` cursor for pagination; use `limit` to control result count. +Search uses the unified `/api/v2/search` REST endpoint. Results are ranked by relevance and each result has a `type` discriminator (`collection`, `nft`, `token`, or `account`) with the corresponding typed object. The search endpoint does not support cursor-based pagination; use `limit` to control result count (max 50). ```typescript -const collections = await client.search.collections("mfers", { +const { results } = await client.search.query("mfers", { + assetTypes: ["collection", "nft"], chains: ["ethereum"], - limit: 5, -}) - -const nfts = await client.search.nfts("cool cat", { - collection: "cool-cats-nft", - chains: ["ethereum"], - limit: 5, -}) - -const tokens = await client.search.tokens("usdc", { - chain: "base", - limit: 5, + limit: 10, }) -const accounts = await client.search.accounts("vitalik", { limit: 5 }) +// Each result has a type and the corresponding object +for (const result of results) { + switch (result.type) { + case "collection": + console.log(result.collection?.name) + break + case "nft": + console.log(result.nft?.name) + break + case "token": + console.log(result.token?.symbol) + break + case "account": + console.log(result.account?.username) + break + } +} ``` ## Swaps diff --git a/package.json b/package.json index a94bf8f..056830f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensea/cli", - "version": "0.4.0", + "version": "0.4.1", "type": "module", "description": "OpenSea CLI - Query the OpenSea API from the command line or programmatically", "main": "dist/index.js", diff --git a/src/client.ts b/src/client.ts index c48c49b..41eba54 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,13 +1,11 @@ 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 @@ -15,7 +13,6 @@ export class OpenSeaClient { 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 @@ -104,54 +101,6 @@ export class OpenSeaClient { return response.json() as Promise } - async graphql( - query: string, - variables?: Record, - ): Promise { - if (this.verbose) { - console.error(`[verbose] POST ${this.graphqlUrl}`) - } - - const response = await fetch(this.graphqlUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "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") - } - - const json = (await response.json()) as { - data?: T - errors?: { message: string }[] - } - - if (json.errors?.length) { - throw new OpenSeaAPIError( - 400, - json.errors.map(e => e.message).join("; "), - "graphql", - ) - } - - if (!json.data) { - throw new OpenSeaAPIError(500, "GraphQL response missing data", "graphql") - } - - return json.data - } - getDefaultChain(): string { return this.defaultChain } diff --git a/src/commands/search.ts b/src/commands/search.ts index b9aef4b..3203f02 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -3,107 +3,48 @@ import type { OpenSeaClient } from "../client.js" import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" -import { - SEARCH_ACCOUNTS_QUERY, - SEARCH_COLLECTIONS_QUERY, - SEARCH_NFTS_QUERY, - SEARCH_TOKENS_QUERY, -} from "../queries.js" -import type { - SearchAccountResult, - SearchCollectionResult, - SearchNFTResult, - SearchTokenResult, -} from "../types/index.js" +import type { SearchResponse } from "../types/index.js" export function searchCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, ): Command { - const cmd = new Command("search").description( - "Search for collections, NFTs, tokens, and accounts", - ) - - cmd - .command("collections") - .description("Search collections by name or slug") + const cmd = new Command("search") + .description("Search across collections, tokens, NFTs, and accounts") .argument("", "Search query") - .option("--chains ", "Filter by chains (comma-separated)") - .option("--limit ", "Number of results", "10") - .action( - async (query: string, options: { chains?: string; limit: string }) => { - const client = getClient() - const result = await client.graphql<{ - collectionsByQuery: SearchCollectionResult[] - }>(SEARCH_COLLECTIONS_QUERY, { - query, - limit: parseIntOption(options.limit, "--limit"), - chains: options.chains?.split(","), - }) - console.log(formatOutput(result.collectionsByQuery, getFormat())) - }, + .option( + "--types ", + "Filter by type (comma-separated: collection,nft,token,account)", ) - - cmd - .command("nfts") - .description("Search NFTs by name") - .argument("", "Search query") - .option("--collection ", "Filter by collection slug") .option("--chains ", "Filter by chains (comma-separated)") - .option("--limit ", "Number of results", "10") + .option("--limit ", "Number of results", "20") .action( async ( query: string, - options: { collection?: string; chains?: string; limit: string }, + options: { + types?: string + chains?: string + limit: string + }, ) => { const client = getClient() - const result = await client.graphql<{ - itemsByQuery: SearchNFTResult[] - }>(SEARCH_NFTS_QUERY, { + const params: Record = { query, - collectionSlug: options.collection, limit: parseIntOption(options.limit, "--limit"), - chains: options.chains?.split(","), - }) - console.log(formatOutput(result.itemsByQuery, getFormat())) + } + if (options.types) { + params.asset_types = options.types + } + if (options.chains) { + params.chains = options.chains + } + const result = await client.get( + "/api/v2/search", + params, + ) + console.log(formatOutput(result, getFormat())) }, ) - cmd - .command("tokens") - .description("Search tokens/currencies by name or symbol") - .argument("", "Search query") - .option("--chain ", "Filter by chain") - .option("--limit ", "Number of results", "10") - .action( - async (query: string, options: { chain?: string; limit: string }) => { - const client = getClient() - const result = await client.graphql<{ - currenciesByQuery: SearchTokenResult[] - }>(SEARCH_TOKENS_QUERY, { - query, - limit: parseIntOption(options.limit, "--limit"), - chain: options.chain, - }) - console.log(formatOutput(result.currenciesByQuery, getFormat())) - }, - ) - - cmd - .command("accounts") - .description("Search accounts by username or address") - .argument("", "Search query") - .option("--limit ", "Number of results", "10") - .action(async (query: string, options: { limit: string }) => { - const client = getClient() - const result = await client.graphql<{ - accountsByQuery: SearchAccountResult[] - }>(SEARCH_ACCOUNTS_QUERY, { - query, - limit: parseIntOption(options.limit, "--limit"), - }) - console.log(formatOutput(result.accountsByQuery, getFormat())) - }) - return cmd } diff --git a/src/queries.ts b/src/queries.ts deleted file mode 100644 index b289a6c..0000000 --- a/src/queries.ts +++ /dev/null @@ -1,94 +0,0 @@ -export const SEARCH_COLLECTIONS_QUERY = ` -query SearchCollections($query: String!, $limit: Int, $chains: [ChainIdentifier!]) { - collectionsByQuery(query: $query, limit: $limit, chains: $chains) { - slug - name - description - imageUrl - chain { - identifier - name - } - stats { - totalSupply - ownerCount - volume { - usd - } - sales - } - floorPrice { - pricePerItem { - usd - native { - unit - symbol - } - } - } - } -}` - -export const SEARCH_NFTS_QUERY = ` -query SearchItems($query: String!, $collectionSlug: String, $limit: Int, $chains: [ChainIdentifier!]) { - itemsByQuery(query: $query, collectionSlug: $collectionSlug, limit: $limit, chains: $chains) { - tokenId - name - description - imageUrl - contractAddress - collection { - slug - name - } - chain { - identifier - name - } - bestListing { - pricePerItem { - usd - native { - unit - symbol - } - } - } - owner { - address - displayName - } - } -}` - -export const SEARCH_TOKENS_QUERY = ` -query SearchCurrencies($query: String!, $limit: Int, $chain: ChainIdentifier) { - currenciesByQuery(query: $query, limit: $limit, chain: $chain, allowlistOnly: false) { - name - symbol - imageUrl - usdPrice - contractAddress - chain { - identifier - name - } - stats { - marketCapUsd - oneDay { - priceChange - volume - } - } - } -}` - -export const SEARCH_ACCOUNTS_QUERY = ` -query SearchAccounts($query: String!, $limit: Int) { - accountsByQuery(query: $query, limit: $limit) { - address - username - imageUrl - isVerified - } -}` diff --git a/src/sdk.ts b/src/sdk.ts index 6819fc7..12c5cd0 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,10 +1,4 @@ import { OpenSeaClient } from "./client.js" -import { - SEARCH_ACCOUNTS_QUERY, - SEARCH_COLLECTIONS_QUERY, - SEARCH_NFTS_QUERY, - SEARCH_TOKENS_QUERY, -} from "./queries.js" import type { Account, AssetEvent, @@ -19,10 +13,8 @@ import type { NFT, Offer, OpenSeaClientConfig, - SearchAccountResult, - SearchCollectionResult, - SearchNFTResult, - SearchTokenResult, + SearchAssetType, + SearchResponse, SwapQuoteResponse, Token, TokenDetails, @@ -350,60 +342,20 @@ class TokensAPI { class SearchAPI { constructor(private client: OpenSeaClient) {} - async collections( + async query( query: string, - options?: { chains?: string[]; limit?: number }, - ): Promise { - const result = await this.client.graphql<{ - collectionsByQuery: SearchCollectionResult[] - }>(SEARCH_COLLECTIONS_QUERY, { - query, - limit: options?.limit, - chains: options?.chains, - }) - return result.collectionsByQuery - } - - async nfts( - query: string, - options?: { collection?: string; chains?: string[]; limit?: number }, - ): Promise { - const result = await this.client.graphql<{ - itemsByQuery: SearchNFTResult[] - }>(SEARCH_NFTS_QUERY, { - query, - collectionSlug: options?.collection, - limit: options?.limit, - chains: options?.chains, - }) - return result.itemsByQuery - } - - async tokens( - query: string, - options?: { chain?: string; limit?: number }, - ): Promise { - const result = await this.client.graphql<{ - currenciesByQuery: SearchTokenResult[] - }>(SEARCH_TOKENS_QUERY, { - query, - limit: options?.limit, - chain: options?.chain, - }) - return result.currenciesByQuery - } - - async accounts( - query: string, - options?: { limit?: number }, - ): Promise { - const result = await this.client.graphql<{ - accountsByQuery: SearchAccountResult[] - }>(SEARCH_ACCOUNTS_QUERY, { + options?: { + assetTypes?: SearchAssetType[] + chains?: string[] + limit?: number + }, + ): Promise { + return this.client.get("/api/v2/search", { query, + asset_types: options?.assetTypes?.join(","), + chains: options?.chains?.join(","), limit: options?.limit, }) - return result.accountsByQuery } } diff --git a/src/types/api.ts b/src/types/api.ts index 61a9358..aae7591 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -317,59 +317,52 @@ export interface SwapQuoteResponse { transactions: SwapTransaction[] } -export interface SearchCollectionResult { - slug: string - name: string - description: string - imageUrl: string - chain: { identifier: string; name: string } - stats: { - totalSupply: number - ownerCount: number - volume: { usd: number } - sales: number - } | null - floorPrice: { - pricePerItem: { - usd: number - native: { unit: number; symbol: string } - } - } | null -} +export type SearchAssetType = "collection" | "nft" | "token" | "account" -export interface SearchNFTResult { - tokenId: string +export interface SearchResultCollection { + collection: string name: string - description: string - imageUrl: string - contractAddress: string - collection: { slug: string; name: string } - chain: { identifier: string; name: string } - bestListing: { - pricePerItem: { - usd: number - native: { unit: number; symbol: string } - } - } | null - owner: { address: string; displayName: string } | null + image_url: string | null + is_disabled: boolean + is_nsfw: boolean + opensea_url: string } -export interface SearchTokenResult { +export interface SearchResultToken { + address: string + chain: string name: string symbol: string - imageUrl: string - usdPrice: string - contractAddress: string - chain: { identifier: string; name: string } - stats: { - marketCapUsd: number - oneDay: { priceChange: number; volume: number } - } | null + image_url: string | null + usd_price: string + decimals: number + opensea_url: string } -export interface SearchAccountResult { +export interface SearchResultNFT { + identifier: string + collection: string + contract: string + name: string | null + image_url: string | null + opensea_url: string +} + +export interface SearchResultAccount { address: string - username: string - imageUrl: string - isVerified: boolean + username: string | null + profile_image_url: string | null + opensea_url: string +} + +export interface SearchResult { + type: SearchAssetType + collection?: SearchResultCollection + token?: SearchResultToken + nft?: SearchResultNFT + account?: SearchResultAccount +} + +export interface SearchResponse { + results: SearchResult[] } diff --git a/src/types/index.ts b/src/types/index.ts index a2be4b2..08ae815 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,7 +3,6 @@ export type * from "./api.js" export interface OpenSeaClientConfig { apiKey: string baseUrl?: string - graphqlUrl?: string chain?: string timeout?: number verbose?: boolean diff --git a/test/client.test.ts b/test/client.test.ts index 79ac428..d068dde 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -140,87 +140,6 @@ describe("OpenSeaClient", () => { }) }) - describe("graphql", () => { - it("makes POST request to graphql URL with correct headers and body", async () => { - const mockData = { collectionsByQuery: [{ slug: "test" }] } - mockFetchResponse({ data: mockData }) - - const result = await client.graphql( - "query { collectionsByQuery { slug } }", - { 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) - }) - - it("uses custom graphqlUrl when configured", async () => { - const custom = new OpenSeaClient({ - apiKey: "key", - graphqlUrl: "https://custom-gql.example.com/graphql", - }) - mockFetchResponse({ data: {} }) - - await custom.graphql("query { test }") - - const calledUrl = vi.mocked(fetch).mock.calls[0][0] as string - expect(calledUrl).toBe("https://custom-gql.example.com/graphql") - }) - - it("throws OpenSeaAPIError on non-ok HTTP response", async () => { - mockFetchTextResponse("Unauthorized", 401) - - await expect(client.graphql("query { test }")).rejects.toThrow( - OpenSeaAPIError, - ) - }) - - it("throws OpenSeaAPIError when response contains GraphQL errors", async () => { - mockFetchResponse({ - errors: [{ message: "Field not found" }, { message: "Invalid query" }], - }) - - try { - await client.graphql("query { bad }") - expect.fail("Should have thrown") - } catch (err) { - expect(err).toBeInstanceOf(OpenSeaAPIError) - const apiErr = err as OpenSeaAPIError - expect(apiErr.statusCode).toBe(400) - expect(apiErr.responseBody).toBe("Field not found; Invalid query") - expect(apiErr.path).toBe("graphql") - } - }) - - it("throws OpenSeaAPIError when response data is missing", async () => { - mockFetchResponse({}) - - try { - await client.graphql("query { test }") - expect.fail("Should have thrown") - } catch (err) { - expect(err).toBeInstanceOf(OpenSeaAPIError) - const apiErr = err as OpenSeaAPIError - expect(apiErr.statusCode).toBe(500) - expect(apiErr.responseBody).toBe("GraphQL response missing data") - } - }) - }) - describe("timeout", () => { it("passes AbortSignal.timeout to fetch calls", async () => { const timedClient = new OpenSeaClient({ @@ -289,21 +208,6 @@ describe("OpenSeaClient", () => { 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", () => { diff --git a/test/commands/search.test.ts b/test/commands/search.test.ts index 11ef73a..f1375e4 100644 --- a/test/commands/search.test.ts +++ b/test/commands/search.test.ts @@ -13,163 +13,120 @@ describe("searchCommand", () => { vi.restoreAllMocks() }) - it("creates command with correct name and subcommands", () => { + it("creates command with correct name", () => { const cmd = searchCommand(ctx.getClient, ctx.getFormat) expect(cmd.name()).toBe("search") - const subcommands = cmd.commands.map(c => c.name()) - expect(subcommands).toContain("collections") - expect(subcommands).toContain("nfts") - expect(subcommands).toContain("tokens") - expect(subcommands).toContain("accounts") }) - it("collections subcommand calls graphql with query and default limit", async () => { - ctx.mockClient.graphql.mockResolvedValue({ - collectionsByQuery: [{ slug: "mfers", name: "mfers" }], + it("calls GET /api/v2/search with query and default limit", async () => { + ctx.mockClient.get.mockResolvedValue({ + results: [{ type: "collection", collection: { collection: "mfers" } }], }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["collections", "mfers"], { from: "user" }) + await cmd.parseAsync(["mfers"], { from: "user" }) - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("collectionsByQuery"), - expect.objectContaining({ query: "mfers", limit: 10 }), + expect(ctx.mockClient.get).toHaveBeenCalledWith( + "/api/v2/search", + expect.objectContaining({ query: "mfers", limit: 20 }), ) expect(ctx.consoleSpy).toHaveBeenCalled() }) - it("collections subcommand passes chains and limit options", async () => { - ctx.mockClient.graphql.mockResolvedValue({ collectionsByQuery: [] }) + it("passes types option as asset_types param", async () => { + ctx.mockClient.get.mockResolvedValue({ results: [] }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync( - ["collections", "ape", "--chains", "ethereum,base", "--limit", "5"], - { from: "user" }, - ) + await cmd.parseAsync(["ape", "--types", "collection,nft"], { from: "user" }) - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("collectionsByQuery"), + expect(ctx.mockClient.get).toHaveBeenCalledWith( + "/api/v2/search", expect.objectContaining({ query: "ape", - limit: 5, - chains: ["ethereum", "base"], + asset_types: "collection,nft", }), ) }) - it("nfts subcommand calls graphql with query", async () => { - ctx.mockClient.graphql.mockResolvedValue({ - itemsByQuery: [{ tokenId: "1", name: "Cool Cat #1" }], - }) + it("passes chains option", async () => { + ctx.mockClient.get.mockResolvedValue({ results: [] }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["nfts", "cool cat"], { from: "user" }) - - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("itemsByQuery"), - expect.objectContaining({ query: "cool cat", limit: 10 }), - ) - expect(ctx.consoleSpy).toHaveBeenCalled() - }) - - it("nfts subcommand passes collection, chains, and limit options", async () => { - ctx.mockClient.graphql.mockResolvedValue({ itemsByQuery: [] }) + await cmd.parseAsync(["ape", "--chains", "ethereum,base"], { from: "user" }) - const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync( - [ - "nfts", - "ape", - "--collection", - "boredapeyachtclub", - "--chains", - "ethereum", - "--limit", - "3", - ], - { from: "user" }, - ) - - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("itemsByQuery"), + expect(ctx.mockClient.get).toHaveBeenCalledWith( + "/api/v2/search", expect.objectContaining({ query: "ape", - collectionSlug: "boredapeyachtclub", - limit: 3, - chains: ["ethereum"], + chains: "ethereum,base", }), ) }) - it("tokens subcommand calls graphql with query", async () => { - ctx.mockClient.graphql.mockResolvedValue({ - currenciesByQuery: [{ name: "USDC", symbol: "USDC" }], - }) + it("passes custom limit", async () => { + ctx.mockClient.get.mockResolvedValue({ results: [] }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["tokens", "usdc"], { from: "user" }) + await cmd.parseAsync(["eth", "--limit", "5"], { from: "user" }) - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("currenciesByQuery"), - expect.objectContaining({ query: "usdc", limit: 10 }), + expect(ctx.mockClient.get).toHaveBeenCalledWith( + "/api/v2/search", + expect.objectContaining({ query: "eth", limit: 5 }), ) - expect(ctx.consoleSpy).toHaveBeenCalled() }) - it("tokens subcommand passes chain and limit options", async () => { - ctx.mockClient.graphql.mockResolvedValue({ currenciesByQuery: [] }) + it("does not include asset_types or chains when not specified", async () => { + ctx.mockClient.get.mockResolvedValue({ results: [] }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["tokens", "eth", "--chain", "base", "--limit", "3"], { - from: "user", - }) + await cmd.parseAsync(["test"], { from: "user" }) - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("currenciesByQuery"), - expect.objectContaining({ query: "eth", limit: 3, chain: "base" }), - ) + const params = ctx.mockClient.get.mock.calls[0][1] + expect(params.asset_types).toBeUndefined() + expect(params.chains).toBeUndefined() }) - it("accounts subcommand calls graphql with query", async () => { - ctx.mockClient.graphql.mockResolvedValue({ - accountsByQuery: [{ address: "0xabc", username: "vitalik" }], + it("outputs in table format when getFormat returns table", async () => { + ctx.mockClient.get.mockResolvedValue({ + results: [ + { + type: "collection", + collection: { collection: "test", name: "Test" }, + }, + ], }) + ctx.getFormat = () => "table" const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["accounts", "vitalik"], { from: "user" }) + await cmd.parseAsync(["test"], { from: "user" }) - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("accountsByQuery"), - expect.objectContaining({ query: "vitalik", limit: 10 }), - ) expect(ctx.consoleSpy).toHaveBeenCalled() + const output = ctx.consoleSpy.mock.calls[0][0] as string + expect(output).toContain("type") }) - it("accounts subcommand passes limit option", async () => { - ctx.mockClient.graphql.mockResolvedValue({ accountsByQuery: [] }) + it("passes all options together", async () => { + ctx.mockClient.get.mockResolvedValue({ results: [] }) const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["accounts", "user", "--limit", "5"], { - from: "user", - }) - - expect(ctx.mockClient.graphql).toHaveBeenCalledWith( - expect.stringContaining("accountsByQuery"), - expect.objectContaining({ query: "user", limit: 5 }), + await cmd.parseAsync( + [ + "bored ape", + "--types", + "collection,nft", + "--chains", + "ethereum", + "--limit", + "10", + ], + { from: "user" }, ) - }) - it("outputs in table format when getFormat returns table", async () => { - ctx.mockClient.graphql.mockResolvedValue({ - collectionsByQuery: [{ slug: "test", name: "Test" }], + expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/search", { + query: "bored ape", + asset_types: "collection,nft", + chains: "ethereum", + limit: 10, }) - ctx.getFormat = () => "table" - - const cmd = searchCommand(ctx.getClient, ctx.getFormat) - await cmd.parseAsync(["collections", "test"], { from: "user" }) - - expect(ctx.consoleSpy).toHaveBeenCalled() - const output = ctx.consoleSpy.mock.calls[0][0] as string - expect(output).toContain("slug") }) }) diff --git a/test/mocks.ts b/test/mocks.ts index d93175c..2167fc8 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -5,7 +5,6 @@ import type { OutputFormat } from "../src/output.js" export type MockClient = { get: Mock post: Mock - graphql: Mock } export type CommandTestContext = { @@ -19,7 +18,6 @@ export function createCommandTestContext(): CommandTestContext { const mockClient: MockClient = { get: vi.fn(), post: vi.fn(), - graphql: vi.fn(), } const getClient = () => mockClient as unknown as OpenSeaClient const getFormat = () => "json" as OutputFormat diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 38b9d81..bafb50b 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -6,7 +6,6 @@ vi.mock("../src/client.js", () => { const MockOpenSeaClient = vi.fn() MockOpenSeaClient.prototype.get = vi.fn() MockOpenSeaClient.prototype.post = vi.fn() - MockOpenSeaClient.prototype.graphql = vi.fn() MockOpenSeaClient.prototype.getDefaultChain = vi.fn(() => "ethereum") return { OpenSeaClient: MockOpenSeaClient, OpenSeaAPIError: vi.fn() } }) @@ -15,13 +14,11 @@ describe("OpenSeaCLI", () => { let sdk: OpenSeaCLI let mockGet: ReturnType let mockPost: ReturnType - let mockGraphql: ReturnType beforeEach(() => { sdk = new OpenSeaCLI({ apiKey: "test-key" }) mockGet = vi.mocked(OpenSeaClient.prototype.get) mockPost = vi.mocked(OpenSeaClient.prototype.post) - mockGraphql = vi.mocked(OpenSeaClient.prototype.graphql) }) afterEach(() => { @@ -342,67 +339,47 @@ describe("OpenSeaCLI", () => { }) describe("search", () => { - it("collections calls graphql with correct query and variables", async () => { - mockGraphql.mockResolvedValue({ - collectionsByQuery: [{ slug: "mfers" }], + it("query calls correct endpoint with all options", async () => { + const mockResponse = { + results: [{ type: "collection", collection: { collection: "mfers" } }], + } + mockGet.mockResolvedValue(mockResponse) + const result = await sdk.search.query("mfers", { + assetTypes: ["collection", "nft"], + chains: ["ethereum"], + limit: 5, }) - const result = await sdk.search.collections("mfers", { + expect(mockGet).toHaveBeenCalledWith("/api/v2/search", { + query: "mfers", + asset_types: "collection,nft", + chains: "ethereum", limit: 5, - chains: ["ethereum"], }) - expect(mockGraphql).toHaveBeenCalledWith( - expect.stringContaining("collectionsByQuery"), - { query: "mfers", limit: 5, chains: ["ethereum"] }, - ) - expect(result).toEqual([{ slug: "mfers" }]) + expect(result).toEqual(mockResponse) }) - it("nfts calls graphql with correct query and variables", async () => { - mockGraphql.mockResolvedValue({ - itemsByQuery: [{ tokenId: "1", name: "Ape #1" }], - }) - const result = await sdk.search.nfts("ape", { - collection: "boredapeyachtclub", - limit: 3, - chains: ["ethereum"], + it("query calls with no options", async () => { + mockGet.mockResolvedValue({ results: [] }) + await sdk.search.query("test") + expect(mockGet).toHaveBeenCalledWith("/api/v2/search", { + query: "test", + asset_types: undefined, + chains: undefined, + limit: undefined, }) - expect(mockGraphql).toHaveBeenCalledWith( - expect.stringContaining("itemsByQuery"), - { - query: "ape", - collectionSlug: "boredapeyachtclub", - limit: 3, - chains: ["ethereum"], - }, - ) - expect(result).toEqual([{ tokenId: "1", name: "Ape #1" }]) }) - it("tokens calls graphql with correct query and variables", async () => { - mockGraphql.mockResolvedValue({ - currenciesByQuery: [{ name: "USDC", symbol: "USDC" }], + it("query calls with multiple chains", async () => { + mockGet.mockResolvedValue({ results: [] }) + await sdk.search.query("ape", { + chains: ["ethereum", "base"], }) - const result = await sdk.search.tokens("usdc", { - chain: "base", - limit: 10, - }) - expect(mockGraphql).toHaveBeenCalledWith( - expect.stringContaining("currenciesByQuery"), - { query: "usdc", limit: 10, chain: "base" }, - ) - expect(result).toEqual([{ name: "USDC", symbol: "USDC" }]) - }) - - it("accounts calls graphql with correct query and variables", async () => { - mockGraphql.mockResolvedValue({ - accountsByQuery: [{ address: "0xabc", username: "vitalik" }], + expect(mockGet).toHaveBeenCalledWith("/api/v2/search", { + query: "ape", + asset_types: undefined, + chains: "ethereum,base", + limit: undefined, }) - const result = await sdk.search.accounts("vitalik", { limit: 5 }) - expect(mockGraphql).toHaveBeenCalledWith( - expect.stringContaining("accountsByQuery"), - { query: "vitalik", limit: 5 }, - ) - expect(result).toEqual([{ address: "0xabc", username: "vitalik" }]) }) })