diff --git a/src/hooks/useErc20Balance.test.ts b/src/hooks/useErc20Balance.test.ts new file mode 100644 index 00000000..04b3f033 --- /dev/null +++ b/src/hooks/useErc20Balance.test.ts @@ -0,0 +1,86 @@ +import type { Token } from '@/src/types/token' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { createElement } from 'react' +import { zeroAddress } from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useErc20Balance } from './useErc20Balance' + +const mockReadContract = vi.fn() + +vi.mock('wagmi', () => ({ + usePublicClient: vi.fn(() => ({ + readContract: mockReadContract, + })), +})) + +vi.mock('@/src/env', () => ({ + env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() }, +})) + +const wrapper = ({ children }: { children: ReactNode }) => + createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children, + ) + +const mockToken: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', +} + +const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as `0x${string}` + +describe('useErc20Balance', () => { + beforeEach(() => { + mockReadContract.mockClear() + }) + + it('returns undefined balance when address is missing', () => { + const { result } = renderHook(() => useErc20Balance({ token: mockToken }), { wrapper }) + expect(result.current.balance).toBeUndefined() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('returns undefined balance when token is missing', () => { + const { result } = renderHook(() => useErc20Balance({ address: walletAddress }), { wrapper }) + expect(result.current.balance).toBeUndefined() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('does not fetch balance for native token address', () => { + const nativeToken: Token = { ...mockToken, address: zeroAddress } + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: nativeToken }), + { wrapper }, + ) + expect(mockReadContract).not.toHaveBeenCalled() + expect(result.current.isLoadingBalance).toBe(false) + }) + + it('returns balance when query resolves', async () => { + mockReadContract.mockResolvedValueOnce(BigInt(1_000_000)) + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: mockToken }), + { wrapper }, + ) + await waitFor(() => expect(result.current.isLoadingBalance).toBe(false)) + expect(result.current.balance).toBe(BigInt(1_000_000)) + expect(result.current.balanceError).toBeNull() + }) + + it('returns error when query fails', async () => { + mockReadContract.mockRejectedValueOnce(new Error('RPC error')) + const { result } = renderHook( + () => useErc20Balance({ address: walletAddress, token: mockToken }), + { wrapper }, + ) + await waitFor(() => expect(result.current.balanceError).toBeTruthy()) + expect(result.current.balance).toBeUndefined() + }) +}) diff --git a/src/hooks/useTokenLists.test.ts b/src/hooks/useTokenLists.test.ts new file mode 100644 index 00000000..bb8ddc0f --- /dev/null +++ b/src/hooks/useTokenLists.test.ts @@ -0,0 +1,136 @@ +import type { Token } from '@/src/types/token' +import tokenListsCache, { updateTokenListsCache } from '@/src/utils/tokenListsCache' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { createElement } from 'react' +import type { ReactNode } from 'react' +import { zeroAddress } from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/src/utils/tokenListsCache', () => { + const cache = { tokens: [] as Token[], tokensByChainId: {} as Record } + return { + default: cache, + updateTokenListsCache: vi.fn((map: typeof cache) => { + cache.tokens = map.tokens + cache.tokensByChainId = map.tokensByChainId + }), + addTokenToTokenList: vi.fn(), + } +}) + +vi.mock('@/src/env', () => ({ + env: { + PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(), + PUBLIC_USE_DEFAULT_TOKENS: false, + }, +})) + +vi.mock('@/src/constants/tokenLists', () => ({ + tokenLists: {}, +})) + +vi.mock('@tanstack/react-query', async (importActual) => { + const actual = await importActual() + return { ...actual, useSuspenseQueries: vi.fn() } +}) + +import * as tanstackQuery from '@tanstack/react-query' +import { useTokenLists } from './useTokenLists' + +const mockToken1: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', +} +const mockToken2: Token = { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + chainId: 1, + decimals: 6, + name: 'Tether USD', + symbol: 'USDT', +} + +const mockSuspenseQueryResult = (tokens: Token[]) => ({ + data: { name: 'Mock List', timestamp: '', version: { major: 1, minor: 0, patch: 0 }, tokens }, + isLoading: false, + isSuccess: true, + error: null, +}) + +const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: new QueryClient() }, children) + +beforeEach(() => { + // Reset cache between tests + tokenListsCache.tokens = [] + tokenListsCache.tokensByChainId = {} + vi.mocked(updateTokenListsCache).mockImplementation((map) => { + tokenListsCache.tokens = map.tokens + tokenListsCache.tokensByChainId = map.tokensByChainId + }) +}) + +describe('useTokenLists', () => { + it('returns tokens and tokensByChainId', () => { + vi.mocked(tanstackQuery.useSuspenseQueries).mockReturnValueOnce( + // biome-ignore lint/suspicious/noExplicitAny: mocking overloaded hook return type + { tokens: [mockToken1], tokensByChainId: { 1: [mockToken1] } } as any, + ) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + expect(result.current.tokens).toBeDefined() + expect(result.current.tokensByChainId).toBeDefined() + }) + + it('deduplicates tokens with the same chainId and address', () => { + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => { + const results = [ + mockSuspenseQueryResult([mockToken1, mockToken2]), + mockSuspenseQueryResult([{ ...mockToken1 }]), // duplicate + ] + return combine(results) + }) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase()) + expect(erc20Tokens).toHaveLength(2) + expect(erc20Tokens.map((t) => t.symbol)).toContain('USDC') + expect(erc20Tokens.map((t) => t.symbol)).toContain('USDT') + }) + + it('injects a native ETH token for mainnet (chainId 1) tokens', () => { + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation( + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + ({ combine }: any) => combine([mockSuspenseQueryResult([mockToken1])]), + ) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const nativeToken = result.current.tokensByChainId[1]?.[0] + expect(nativeToken?.address).toBe(zeroAddress.toLowerCase()) + expect(nativeToken?.symbol).toBe('ETH') + }) + + it('filters out tokens that fail schema validation', () => { + // biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param + vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => { + const invalidToken = { + address: 'not-an-address', + chainId: 1, + name: 'Bad', + symbol: 'BAD', + decimals: 18, + } + // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid token input + return combine([mockSuspenseQueryResult([mockToken1, invalidToken as any])]) + }) + + const { result } = renderHook(() => useTokenLists(), { wrapper }) + const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase()) + expect(erc20Tokens).toHaveLength(1) + expect(erc20Tokens[0].symbol).toBe('USDC') + }) +}) diff --git a/src/hooks/useWeb3Status.test.ts b/src/hooks/useWeb3Status.test.ts new file mode 100644 index 00000000..1010ea0b --- /dev/null +++ b/src/hooks/useWeb3Status.test.ts @@ -0,0 +1,126 @@ +import { renderHook } from '@testing-library/react' +import type { Address } from 'viem' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status' + +const mockDisconnect = vi.fn() +const mockSwitchChain = vi.fn() + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(() => ({ + address: undefined, + chainId: undefined, + isConnected: false, + isConnecting: false, + })), + useChainId: vi.fn(() => 1), + useSwitchChain: vi.fn(() => ({ isPending: false, switchChain: mockSwitchChain })), + usePublicClient: vi.fn(() => undefined), + useWalletClient: vi.fn(() => ({ data: undefined })), + useBalance: vi.fn(() => ({ data: undefined })), + useDisconnect: vi.fn(() => ({ disconnect: mockDisconnect })), +})) + +import * as wagmi from 'wagmi' + +type MockAccount = ReturnType +type MockSwitchChain = ReturnType + +describe('useWeb3Status', () => { + beforeEach(() => { + mockDisconnect.mockClear() + mockSwitchChain.mockClear() + }) + + it('returns disconnected state when no wallet connected', () => { + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletConnected).toBe(false) + expect(result.current.address).toBeUndefined() + expect(result.current.walletChainId).toBeUndefined() + }) + + it('returns connected state with wallet address', () => { + const mock = { + address: '0xabc123' as Address, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletConnected).toBe(true) + expect(result.current.address).toBe('0xabc123') + }) + + it('sets isWalletSynced true when wallet chainId matches app chainId', () => { + const mock = { + address: '0xabc123' as Address, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletSynced).toBe(true) + }) + + it('sets isWalletSynced false when wallet chainId differs from app chainId', () => { + const mock = { + address: '0xabc123' as Address, + chainId: 137, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock) + vi.mocked(wagmi.useChainId).mockReturnValueOnce(1) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.isWalletSynced).toBe(false) + }) + + it('sets switchingChain when useSwitchChain is pending', () => { + const mock = { isPending: true, switchChain: mockSwitchChain } as unknown as MockSwitchChain + vi.mocked(wagmi.useSwitchChain).mockReturnValueOnce(mock) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.switchingChain).toBe(true) + }) + + it('exposes disconnect function', () => { + const { result } = renderHook(() => useWeb3Status()) + result.current.disconnect() + expect(mockDisconnect).toHaveBeenCalled() + }) + + it('calls switchChain with chainId when switchChain action is invoked', () => { + const { result } = renderHook(() => useWeb3Status()) + result.current.switchChain(137) + expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 137 }) + }) + + it('exposes appChainId from useChainId', () => { + vi.mocked(wagmi.useChainId).mockReturnValueOnce(42161) + const { result } = renderHook(() => useWeb3Status()) + expect(result.current.appChainId).toBe(42161) + }) +}) + +describe('useWeb3StatusConnected', () => { + it('throws when wallet is not connected', () => { + expect(() => renderHook(() => useWeb3StatusConnected())).toThrow( + 'Use useWeb3StatusConnected only when a wallet is connected', + ) + }) + + it('returns status when wallet is connected', () => { + const mock = { + address: '0xdeadbeef' as Address, + chainId: 1, + isConnected: true, + isConnecting: false, + } as unknown as MockAccount + // useWeb3StatusConnected calls useWeb3Status twice; both calls must see connected state + vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock).mockReturnValueOnce(mock) + const { result } = renderHook(() => useWeb3StatusConnected()) + expect(result.current.isWalletConnected).toBe(true) + }) +})