From e5f1f9fd4728ac49c8dc9fc265984ace43bd418e Mon Sep 17 00:00:00 2001 From: Keegan Date: Thu, 21 May 2026 15:35:32 -0400 Subject: [PATCH] Add targeted coverage for SDK boundaries --- .../sdk/client-api-shaping.test.ts | 327 +++++++++++++++++ test/integration/sdk/package-exports.test.ts | 71 ++++ test/types/public-package-exports.ts | 28 ++ test/unit/commands/swap.test.ts | 2 +- test/unit/sdk/liquid.test.ts | 133 +++++++ test/unit/sdk/validation-core.test.ts | 55 +++ test/unit/swap/uniswap-api.test.ts | 329 ++++++++++++++++++ 7 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 test/integration/sdk/client-api-shaping.test.ts create mode 100644 test/integration/sdk/package-exports.test.ts create mode 100644 test/types/public-package-exports.ts create mode 100644 test/unit/sdk/liquid.test.ts create mode 100644 test/unit/sdk/validation-core.test.ts create mode 100644 test/unit/swap/uniswap-api.test.ts diff --git a/test/integration/sdk/client-api-shaping.test.ts b/test/integration/sdk/client-api-shaping.test.ts new file mode 100644 index 0000000..6ee5cf4 --- /dev/null +++ b/test/integration/sdk/client-api-shaping.test.ts @@ -0,0 +1,327 @@ +/* eslint-disable functional/immutable-data */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { text } from 'node:stream/consumers'; +import { describe, expect, it } from 'vitest'; +import { createPublicClient, http } from 'viem'; +import { mainnet } from 'viem/chains'; +import { createRareClient } from '../../../src/sdk/client.js'; + +type ApiRequest = { + method: string; + pathname: string; + query: Record; + body?: unknown; +}; + +const contract = '0x1000000000000000000000000000000000000000' as const; +const owner = '0x2000000000000000000000000000000000000000' as const; +const account = '0x3000000000000000000000000000000000000000' as const; + +describe('Rare SDK client API request shaping', () => { + it('binds search and NFT event API requests to the client chain', async () => { + await withRareApiFixture(async ({ baseUrl, requests }) => { + const rare = createTestClient(baseUrl); + + await rare.search.nfts({ page: 2, perPage: 3, query: 'portrait' }); + await rare.search.collections({ page: 4, perPage: 5, query: 'editions' }); + await rare.search.events({ + contract, + tokenId: 7, + page: 1, + perPage: 2, + eventType: ['CREATE_NFT'], + }); + await rare.search.events({ + collectionId: 'custom-collection-id', + page: 8, + perPage: 9, + }); + + expect(requests).toEqual([ + expect.objectContaining({ + method: 'GET', + pathname: '/v1/nfts', + query: expect.objectContaining({ + chainId: '1', + page: '2', + perPage: '3', + q: 'portrait', + }), + }), + expect.objectContaining({ + method: 'GET', + pathname: '/v1/collections', + query: expect.objectContaining({ + chainId: '1', + page: '4', + perPage: '5', + q: 'editions', + }), + }), + expect.objectContaining({ + method: 'GET', + pathname: `/v1/nfts/1-${contract}-7/events`, + query: expect.objectContaining({ + page: '1', + perPage: '2', + eventType: 'CREATE_NFT', + }), + }), + expect.objectContaining({ + method: 'GET', + pathname: '/v1/collections/custom-collection-id/events', + query: expect.objectContaining({ + page: '8', + perPage: '9', + }), + }), + ]); + }); + }); + + it('posts metadata and import requests with normalized client-owned bodies', async () => { + await withRareApiFixture(async ({ baseUrl, requests }) => { + const rare = createTestClient(baseUrl, account); + + await rare.media.pinMetadata({ + name: 'Pinned', + description: 'Pinned metadata', + image: { + url: 'ipfs://image', + mimeType: 'image/png', + size: 12, + }, + attributes: [{ trait_type: 'Kind', value: 'Test' }], + }); + await rare.import.erc721({ contract, owner }); + await rare.import.erc721({ contract }); + + expect(requests).toEqual([ + expect.objectContaining({ + method: 'POST', + pathname: '/v1/nfts/metadata', + body: expect.objectContaining({ + name: 'Pinned', + description: 'Pinned metadata', + nftMedia: { + image: { + url: 'ipfs://image', + mimeType: 'image/png', + size: 12, + }, + }, + tags: [], + attributes: [{ trait_type: 'Kind', value: 'Test' }], + }), + }), + expect.objectContaining({ + method: 'POST', + pathname: '/v1/collections/import', + body: { + chainId: 1, + contractAddress: contract, + ownerAddress: owner, + }, + }), + expect.objectContaining({ + method: 'POST', + pathname: '/v1/collections/import', + body: { + chainId: 1, + contractAddress: contract, + ownerAddress: account, + }, + }), + ]); + }); + }); + + it('runs the full media upload handshake against a controlled API fixture', async () => { + await withRareApiFixture(async ({ baseUrl, requests }) => { + const rare = createTestClient(baseUrl); + + const media = await rare.media.upload(new Uint8Array([1, 2, 3, 4]), 'folder/Mint Image.PNG'); + + expect(media).toEqual({ + url: 'ipfs://bafymedia', + mimeType: 'image/png', + size: 4, + dimensions: { width: 1, height: 1 }, + }); + expect(requests.map((request) => request.pathname)).toEqual([ + '/v1/nfts/metadata/media/uploads', + '/upload-part/1', + '/v1/nfts/metadata/media/uploads/complete', + '/v1/nfts/metadata/media/generate', + ]); + expect(requests[0]?.body).toEqual({ + fileSize: 4, + filename: 'Mint Image.PNG', + }); + expect(requests[2]?.body).toEqual({ + key: 'media/Mint Image.PNG', + uploadId: 'upload-1', + bucket: 'rare-cli-test', + parts: [{ ETag: 'fixture-etag', PartNumber: 1 }], + }); + expect(requests[3]?.body).toEqual({ + uri: 'ipfs://bafymedia', + mimeType: 'image/png', + }); + }); + }); +}); + +function createTestClient(baseUrl: string, configuredAccount?: typeof account): ReturnType { + return createRareClient({ + publicClient: createPublicClient({ + chain: mainnet, + transport: http('http://127.0.0.1:8545'), + }), + apiBaseUrl: baseUrl, + ...(configuredAccount === undefined ? {} : { account: configuredAccount }), + }); +} + +async function withRareApiFixture( + fn: (fixture: { baseUrl: string; requests: ApiRequest[] }) => Promise, +): Promise { + const requests: ApiRequest[] = []; + const server = createServer((req, res) => { + void handleRequest(req, res, requests, () => server.address()) + .catch((error: unknown) => { + writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) }); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') { + await closeServer(server); + throw new Error('Rare API fixture server did not bind to a TCP port.'); + } + + try { + return await fn({ + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + }); + } finally { + await closeServer(server); + } +} + +async function handleRequest( + req: IncomingMessage, + res: ServerResponse, + requests: ApiRequest[], + serverAddress: () => ReturnType['address']>, +): Promise { + const url = new URL(req.url ?? '/', 'http://rare-api.test'); + if (url.pathname === '/upload-part/1') { + requests.push({ + method: req.method ?? 'PUT', + pathname: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + }); + res.writeHead(200, { etag: 'fixture-etag' }); + res.end(); + return; + } + + const body = await readJsonBody(req); + requests.push({ + method: req.method ?? 'GET', + pathname: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + ...(body === undefined ? {} : { body }), + }); + + if (url.pathname === '/v1/nfts') { + writeJson(res, 200, page([{ universalTokenId: 'mainnet-token' }], url)); + return; + } + if (url.pathname === '/v1/collections') { + writeJson(res, 200, page([{ collectionId: 'mainnet-collection' }], url)); + return; + } + if (url.pathname.endsWith('/events')) { + writeJson(res, 200, page([], url)); + return; + } + if (url.pathname === '/v1/nfts/metadata') { + writeJson(res, 201, { ipfsUrl: 'ipfs://bafymetadata' }); + return; + } + if (url.pathname === '/v1/collections/import') { + writeJson(res, 200, { imported: true }); + return; + } + if (url.pathname === '/v1/nfts/metadata/media/uploads') { + const address = serverAddress(); + if (address === null || typeof address === 'string') { + writeJson(res, 500, { error: 'fixture server unavailable' }); + return; + } + writeJson(res, 201, { + uploadId: 'upload-1', + key: 'media/Mint Image.PNG', + bucket: 'rare-cli-test', + partSize: 4, + presignedUrls: [`http://127.0.0.1:${address.port}/upload-part/1`], + }); + return; + } + if (url.pathname === '/v1/nfts/metadata/media/uploads/complete') { + writeJson(res, 200, { ipfsUrl: 'ipfs://bafymedia' }); + return; + } + if (url.pathname === '/v1/nfts/metadata/media/generate') { + writeJson(res, 200, { + media: { + uri: 'ipfs://bafymedia', + mimeType: 'image/png', + dimensions: '1x1', + }, + }); + return; + } + + writeJson(res, 404, { error: `Unhandled fixture path: ${url.pathname}` }); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const raw = await text(req); + return raw.length === 0 ? undefined : JSON.parse(raw) as unknown; +} + +function page(data: T[], url: URL): { data: T[]; pagination: { page: number; perPage: number; totalCount: number; totalPages: number } } { + return { + data, + pagination: { + page: Number(url.searchParams.get('page') ?? 1), + perPage: Number(url.searchParams.get('perPage') ?? 24), + totalCount: data.length, + totalPages: 1, + }, + }; +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function closeServer(server: ReturnType): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/test/integration/sdk/package-exports.test.ts b/test/integration/sdk/package-exports.test.ts new file mode 100644 index 0000000..fc93f0d --- /dev/null +++ b/test/integration/sdk/package-exports.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { Address } from 'viem'; +import { ETH_ADDRESS } from '../../../src/contracts/addresses.js'; + +const contractAddress = '0x1111111111111111111111111111111111111111' satisfies Address; + +describe('published package subpath exports', () => { + it('loads the built client, contracts, and utils subpaths through package exports', async () => { + const client = await import('@rareprotocol/rare-cli/client'); + const contracts = await import('@rareprotocol/rare-cli/contracts'); + const utils = await import('@rareprotocol/rare-cli/utils'); + + expect(Object.keys(client).sort()).toEqual([ + 'NftApprovalRequiredError', + 'PaymentApprovalRequiredError', + 'createRareClient', + ]); + expect(contracts).toHaveProperty('getContractAddresses'); + expect(contracts).toHaveProperty('liquidRouterAbi'); + expect(Object.keys(utils).sort()).toEqual([ + 'buildUtilsMerkleProof', + 'buildUtilsTree', + 'getUtilsTreeProof', + 'verifyUtilsTreeProof', + ]); + }); + + it('executes the public utils helpers from the built utils subpath', async () => { + const utils = await import('@rareprotocol/rare-cli/utils'); + + const tree = utils.buildUtilsTree({ + content: [ + 'contract_address,token_id,chain_id', + `${contractAddress},2,11155111`, + `${contractAddress},1,11155111`, + ].join('\n'), + format: 'csv', + }); + const proof = utils.getUtilsTreeProof({ + artifact: tree, + contractAddress, + tokenId: 1, + }); + + expect(tree.tokens.map((token) => token.tokenId)).toEqual(['1', '2']); + expect(proof.valid).toBe(true); + expect(utils.verifyUtilsTreeProof({ + root: tree.root, + contractAddress, + tokenId: 1, + proof: proof.proof, + })).toBe(true); + expect(utils.buildUtilsMerkleProof({ + artifact: { + root: '0xa01f005c90f56c0f2b981e045caf4949f489bf82e5d3c49effb1334cab26043a', + currency: ETH_ADDRESS, + amount: '1', + splitAddresses: [], + splitRatios: [], + tokens: [ + { contract: contractAddress, tokenId: '1' }, + { contract: contractAddress, tokenId: '2' }, + ], + }, + contract: contractAddress, + tokenId: '1', + }).proof).toEqual([ + '0xfde38319eec56e703ba771c1e2abddca86188674940372bdfed26cec392ec314', + ]); + }); +}); diff --git a/test/types/public-package-exports.ts b/test/types/public-package-exports.ts new file mode 100644 index 0000000..2622646 --- /dev/null +++ b/test/types/public-package-exports.ts @@ -0,0 +1,28 @@ +import { createRareClient, type RareClient } from '@rareprotocol/rare-cli/client'; +import { getContractAddresses, type SupportedChain } from '@rareprotocol/rare-cli/contracts'; +import { + buildUtilsTree, + type UtilsTreeArtifact, + type UtilsMerkleProofArtifact, +} from '@rareprotocol/rare-cli/utils'; + +const supportedChain: SupportedChain = 'sepolia'; +const addresses = getContractAddresses(supportedChain); +const tree: UtilsTreeArtifact = buildUtilsTree({ + content: 'contract_address,token_id\n0x1111111111111111111111111111111111111111,1\n', + format: 'csv', +}); +const merkleProof: UtilsMerkleProofArtifact = { + root: `0x${'00'.repeat(32)}`, + contract: '0x1111111111111111111111111111111111111111', + tokenId: '1', + proof: [], +}; +const clientFactory: typeof createRareClient = createRareClient; +declare const maybeClient: RareClient | undefined; + +void addresses; +void tree; +void merkleProof; +void clientFactory; +void maybeClient; diff --git a/test/unit/commands/swap.test.ts b/test/unit/commands/swap.test.ts index a9b9690..d0e6bda 100644 --- a/test/unit/commands/swap.test.ts +++ b/test/unit/commands/swap.test.ts @@ -275,7 +275,7 @@ function tokenQuote(params: { direction: 'buy' | 'sell' }): TokenTradeQuote { }; } -function tokenTradeResult(): TokenTradeResult { +function tokenTradeResult(): Omit & { receipt: { blockNumber: bigint } } { return { txHash: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', receipt: { blockNumber: 123n }, diff --git a/test/unit/sdk/liquid.test.ts b/test/unit/sdk/liquid.test.ts new file mode 100644 index 0000000..6b11e85 --- /dev/null +++ b/test/unit/sdk/liquid.test.ts @@ -0,0 +1,133 @@ +/* eslint-disable no-restricted-syntax, @typescript-eslint/explicit-function-return-type */ +import { describe, expect, it } from 'vitest'; +import { + encodeAbiParameters, + encodeEventTopics, + parseEther, + type Address, + type Hex, + type TransactionReceipt, +} from 'viem'; +import { liquidFactoryAbi } from '../../../src/contracts/abis/liquid-factory.js'; +import { createLiquidNamespace } from '../../../src/sdk/liquid.js'; +import type { LiquidCurveSegment } from '../../../src/liquid/curve-config.js'; + +const accountAddress = '0x1000000000000000000000000000000000000000' as Address; +const liquidFactory = '0x2000000000000000000000000000000000000000' as Address; +const baseToken = '0x3000000000000000000000000000000000000000' as Address; +const deployedToken = '0x4000000000000000000000000000000000000000' as Address; +const txHash = `0x${'12'.repeat(32)}` satisfies Hex; +const curves: LiquidCurveSegment[] = [ + { tickLower: -16080, tickUpper: -9180, numPositions: 3, shares: '0.1' }, + { tickLower: -9180, tickUpper: 6960, numPositions: 2, shares: '0.65' }, + { tickLower: 6960, tickUpper: 29940, numPositions: 2, shares: '0.23' }, + { tickLower: 29940, tickUpper: 76020, numPositions: 1, shares: '0.02' }, +]; + +describe('Liquid Edition SDK shell receipt handling', () => { + it('retries a successful deploy receipt until delayed LiquidTokenCreated logs are available', async () => { + let getReceiptCalls = 0; + const namespace = createTestLiquidNamespace({ + async waitForTransactionReceipt() { + return receipt({ logs: [] }); + }, + async getTransactionReceipt() { + getReceiptCalls += 1; + return receipt({ logs: [liquidTokenCreatedLog()] }); + }, + }); + + const result = await namespace.deploy.multiCurve({ + name: 'Delayed Logs', + symbol: 'DLAY', + tokenUri: 'ipfs://token', + curves, + }); + + expect(getReceiptCalls).toBe(1); + expect(result.contract).toBe(deployedToken); + expect(result.txHash).toBe(txHash); + }); + + it('surfaces reverted deploy receipts before trying to read delayed logs', async () => { + const namespace = createTestLiquidNamespace({ + async waitForTransactionReceipt() { + return receipt({ status: 'reverted', logs: [] }); + }, + async getTransactionReceipt(): Promise { + throw new Error('unexpected delayed receipt lookup'); + }, + }); + + await expect(namespace.deploy.multiCurve({ + name: 'Reverted Logs', + symbol: 'RVRT', + tokenUri: 'ipfs://token', + curves, + })).rejects.toThrow( + `Liquid Edition deploy transaction reverted before emitting LiquidTokenCreated. Transaction hash: ${txHash}. Block: 123.`, + ); + }); +}); + +function createTestLiquidNamespace(publicClientOverrides: { + waitForTransactionReceipt: () => Promise; + getTransactionReceipt: () => Promise; +}) { + const publicClient = { + async readContract(params: { functionName: string }) { + if (params.functionName === 'baseToken') return baseToken; + if (params.functionName === 'maxTotalSupply') return parseEther('1000000'); + if (params.functionName === 'creatorLaunchReward') return parseEther('100000'); + if (params.functionName === 'minRareLiquidityWei') return 0n; + if (params.functionName === 'lpTickLower') return -887_220; + if (params.functionName === 'lpTickUpper') return 887_220; + if (params.functionName === 'poolTickSpacing') return 60; + throw new Error(`unexpected readContract ${params.functionName}`); + }, + waitForTransactionReceipt: publicClientOverrides.waitForTransactionReceipt, + getTransactionReceipt: publicClientOverrides.getTransactionReceipt, + }; + + return createLiquidNamespace( + { + publicClient: publicClient as never, + walletClient: { + account: { address: accountAddress }, + async writeContract(params: { functionName: string; args?: readonly unknown[] }) { + expect(params.functionName).toBe('createLiquidTokenMultiCurve'); + expect(params.args?.[0]).toBe(accountAddress); + return txHash; + }, + } as never, + }, + 'sepolia', + { liquidFactory }, + ); +} + +function receipt(params: { + status?: 'success' | 'reverted'; + logs: TransactionReceipt['logs']; +}): TransactionReceipt { + return { + status: params.status ?? 'success', + blockNumber: 123n, + logs: params.logs, + } as TransactionReceipt; +} + +function liquidTokenCreatedLog(): TransactionReceipt['logs'][number] { + return { + address: liquidFactory, + topics: encodeEventTopics({ + abi: liquidFactoryAbi, + eventName: 'LiquidTokenCreated', + args: { + token: deployedToken, + creator: accountAddress, + }, + }), + data: encodeAbiParameters([{ type: 'string' }], ['ipfs://token']), + } as TransactionReceipt['logs'][number]; +} diff --git a/test/unit/sdk/validation-core.test.ts b/test/unit/sdk/validation-core.test.ts new file mode 100644 index 0000000..4a69eac --- /dev/null +++ b/test/unit/sdk/validation-core.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + requireConfiguredAddress, + requireInput, + toUnixTimestamp, + validateRouterPayload, +} from '../../../src/sdk/validation-core.js'; + +describe('SDK validation core', () => { + it('requires present inputs and configured contract addresses', () => { + expect(requireInput('value', 'field')).toBe('value'); + expect(() => requireInput(undefined, 'field')).toThrow('field is required.'); + + expect(requireConfiguredAddress( + '0x1000000000000000000000000000000000000000', + 'Batch marketplace', + 'sepolia', + )).toBe('0x1000000000000000000000000000000000000000'); + expect(() => requireConfiguredAddress(undefined, 'Batch marketplace', 'base')).toThrow( + 'Batch marketplace is not configured for "base". Supported chains: mainnet, sepolia', + ); + }); + + it('normalizes timestamp inputs from dates, ISO strings, and integer strings', () => { + expect(toUnixTimestamp(new Date('2026-05-21T12:34:56.789Z'), 'startTime')).toBe(1_779_366_896n); + expect(toUnixTimestamp('2026-05-21T12:34:56Z', 'startTime')).toBe(1_779_366_896n); + expect(toUnixTimestamp('1779366896', 'startTime')).toBe(1_779_366_896n); + }); + + it('rejects invalid dates and non-positive timestamp fallbacks', () => { + expect(() => toUnixTimestamp(new Date(Number.NaN), 'startTime')).toThrow( + 'startTime must be a valid date.', + ); + expect(() => toUnixTimestamp('2026-99-99', 'startTime')).toThrow( + 'startTime must be a unix timestamp or ISO date.', + ); + expect(() => toUnixTimestamp('0', 'startTime')).toThrow('startTime must be greater than 0.'); + }); + + it('validates raw router command and input payload shape', () => { + expect(() => validateRouterPayload('0x12', ['0xab'])).not.toThrow(); + expect(() => validateRouterPayload('0xzz', ['0xab'])).toThrow( + 'Router commands must be an even-length hex string.', + ); + expect(() => validateRouterPayload('0x', [])).toThrow( + 'Router commands must not be empty.', + ); + expect(() => validateRouterPayload('0x12', ['0xabc'])).toThrow( + 'Router input at index 0 must be an even-length hex string.', + ); + expect(() => validateRouterPayload('0x1234', ['0xab'])).toThrow( + 'Router commands/input mismatch: commands has 2 byte(s) but 1 input(s) were provided.', + ); + }); +}); diff --git a/test/unit/swap/uniswap-api.test.ts b/test/unit/swap/uniswap-api.test.ts new file mode 100644 index 0000000..3201cbe --- /dev/null +++ b/test/unit/swap/uniswap-api.test.ts @@ -0,0 +1,329 @@ +/* eslint-disable no-restricted-syntax */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Address } from 'viem'; +import { ETH_ADDRESS } from '../../../src/contracts/addresses.js'; +import { + requestUniswapApproval, + requestUniswapQuote, + requestUniswapSwap, + type UniswapQuotePayload, + type UniswapTransactionRequest, +} from '../../../src/swap/uniswap-api.js'; + +const tokenIn = ETH_ADDRESS; +const tokenOut = '0x197FaeF3f59eC80113e773Bb6206a17d183F97CB' satisfies Address; +const swapper = '0x1234567890123456789012345678901234567890' satisfies Address; +const baseUrl = 'https://uniswap.test/v1'; + +describe('Uniswap Trade API client', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('posts quote requests with required headers and parses rich quote payloads', async () => { + const fetchMock = vi.fn(async (): Promise => Response.json(buildQuoteResponse())); + vi.stubGlobal('fetch', fetchMock); + + const quote = await requestUniswapQuote({ + apiKey: 'test-key', + baseUrl, + chainId: 11_155_111, + tokenIn, + tokenOut, + amount: 1_000_000_000_000_000n, + swapper, + slippageBps: 125, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const { url, init } = getFetchCall(fetchMock); + expect(url).toBe(`${baseUrl}/quote`); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': 'test-key', + 'x-permit2-disabled': 'true', + 'x-universal-router-version': '2.0', + }); + expect(parseJsonBody(init)).toEqual({ + type: 'EXACT_INPUT', + tokenInChainId: 11_155_111, + tokenOutChainId: 11_155_111, + amount: '1000000000000000', + tokenIn, + tokenOut, + swapper, + protocols: ['V4', 'V3', 'V2'], + routingPreference: 'BEST_PRICE', + urgency: 'normal', + slippageTolerance: 1.25, + }); + expect(quote.quote.output.amount).toBe('2000'); + expect(quote.quote.aggregatedOutputs?.[0]?.minAmount).toBe('1900'); + }); + + it('requires an API key before sending requests', async () => { + const fetchMock = vi.fn(async (): Promise => { + throw new Error('unexpected fetch'); + }); + vi.stubGlobal('fetch', fetchMock); + + await expect(requestUniswapQuote({ + baseUrl, + chainId: 1, + tokenIn, + tokenOut, + amount: 1n, + swapper, + slippageBps: 50, + })).rejects.toThrow('UNISWAP_API_KEY is required'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces non-OK API errors with response messages', async () => { + vi.stubGlobal('fetch', vi.fn(async (): Promise => Response.json( + { message: 'route unavailable' }, + { status: 400, statusText: 'Bad Request' }, + ))); + + await expect(requestUniswapQuote({ + apiKey: 'test-key', + baseUrl, + chainId: 1, + tokenIn, + tokenOut, + amount: 1n, + swapper, + slippageBps: 50, + })).rejects.toThrow('Uniswap API 400 Bad Request: route unavailable'); + }); + + it('validates quote, approval, and swap response shapes before returning them', async () => { + vi.stubGlobal('fetch', vi.fn(async (): Promise => Response.json({ + ...buildQuoteResponse(), + quote: { + ...buildQuotePayload(), + output: { + amount: '2000', + token: 'not-an-address', + recipient: swapper, + }, + }, + }))); + + await expect(requestUniswapQuote({ + apiKey: 'test-key', + baseUrl, + chainId: 1, + tokenIn, + tokenOut, + amount: 1n, + swapper, + slippageBps: 50, + })).rejects.toThrow('response.quote.output.token'); + + vi.stubGlobal('fetch', vi.fn(async (): Promise => Response.json({ + requestId: 'approval-1', + approval: { + ...buildTransactionRequest(), + data: 'not-hex', + }, + cancel: null, + }))); + + await expect(requestUniswapApproval({ + apiKey: 'test-key', + baseUrl, + chainId: 1, + walletAddress: swapper, + token: tokenOut, + amount: 1n, + tokenOut: tokenIn, + })).rejects.toThrow('response.approval.data'); + + vi.stubGlobal('fetch', vi.fn(async (): Promise => Response.json({ + requestId: 'swap-1', + swap: { + ...buildTransactionRequest(), + chainId: '1', + }, + }))); + + await expect(requestUniswapSwap({ + apiKey: 'test-key', + baseUrl, + quote: buildQuotePayload(), + })).rejects.toThrow('response.swap.chainId'); + }); + + it('posts approval and swap bodies to the expected endpoints', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL): Promise => { + const url = fetchInputUrl(input); + if (url.endsWith('/check_approval')) { + return Response.json({ + requestId: 'approval-1', + approval: buildTransactionRequest(), + cancel: null, + gasFee: '100', + }); + } + return Response.json({ + requestId: 'swap-1', + swap: buildTransactionRequest(), + gasFee: '200', + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const approval = await requestUniswapApproval({ + apiKey: 'test-key', + baseUrl, + chainId: 11_155_111, + walletAddress: swapper, + token: tokenOut, + amount: 123n, + tokenOut: tokenIn, + }); + const swap = await requestUniswapSwap({ + apiKey: 'test-key', + baseUrl, + quote: buildQuotePayload(), + deadline: 1_800_000_000, + }); + + const approvalCall = getFetchCall(fetchMock, 0); + expect(approvalCall.url).toBe(`${baseUrl}/check_approval`); + expect(parseJsonBody(approvalCall.init)).toMatchObject({ + chainId: 11_155_111, + walletAddress: swapper, + token: tokenOut, + amount: '123', + tokenOut: tokenIn, + tokenOutChainId: 11_155_111, + includeGasInfo: true, + urgency: 'normal', + }); + + const swapCall = getFetchCall(fetchMock, 1); + expect(swapCall.url).toBe(`${baseUrl}/swap`); + expect(parseJsonBody(swapCall.init)).toMatchObject({ + quote: buildQuotePayload(), + refreshGasPrice: true, + simulateTransaction: true, + safetyMode: 'SAFE', + urgency: 'normal', + deadline: 1_800_000_000, + }); + expect(approval.approval?.to).toBe(tokenOut); + expect(swap.swap.to).toBe(tokenOut); + }); +}); + +function getFetchCall( + fetchMock: ReturnType, + index = 0, +): { url: string; init: RequestInit } { + const call = fetchMock.mock.calls[index]; + if (call === undefined) { + throw new Error(`Missing fetch call at index ${index}.`); + } + + const [input, init] = call as [RequestInfo | URL, RequestInit | undefined]; + if (init === undefined) { + throw new Error('Expected fetch init.'); + } + const url = fetchInputUrl(input); + return { url, init }; +} + +function fetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function parseJsonBody(init: RequestInit): unknown { + if (typeof init.body !== 'string') { + throw new Error('Expected JSON string request body.'); + } + return JSON.parse(init.body) as unknown; +} + +function buildQuoteResponse(): { + requestId: string; + routing: string; + quote: UniswapQuotePayload; + permitData: null; +} { + return { + requestId: 'quote-1', + routing: 'CLASSIC', + quote: buildQuotePayload(), + permitData: null, + }; +} + +function buildQuotePayload(): UniswapQuotePayload { + return { + chainId: 11_155_111, + input: { + amount: '1000', + token: tokenIn, + }, + output: { + amount: '2000', + token: tokenOut, + recipient: swapper, + }, + swapper, + route: [[{ + type: 'v4-pool', + tokenIn: { + chainId: 11_155_111, + decimals: '18', + address: tokenIn, + symbol: 'ETH', + }, + tokenOut: { + chainId: 11_155_111, + decimals: '18', + address: tokenOut, + symbol: 'RARE', + }, + fee: '3000', + tickSpacing: '60', + hooks: tokenIn, + amountIn: '1000', + amountOut: '2000', + }]], + slippage: 1.25, + tradeType: 'EXACT_INPUT', + quoteId: 'quote-id', + routeString: 'ETH -> RARE', + aggregatedOutputs: [{ + amount: '2000', + token: tokenOut, + recipient: swapper, + bps: 10_000, + minAmount: '1900', + }], + txFailureReasons: ['SIMULATION_UNAVAILABLE'], + }; +} + +function buildTransactionRequest(): UniswapTransactionRequest { + return { + to: tokenOut, + from: swapper, + data: '0x1234', + value: '0', + chainId: 11_155_111, + gasLimit: '21000', + maxFeePerGas: '100', + maxPriorityFeePerGas: '1', + }; +}