Skip to content

Commit 11fb2a4

Browse files
Merge pull request #417 from BootNodeDev/test/hooks
test: add hook test coverage (Tier 3)
2 parents ff358c9 + 5861213 commit 11fb2a4

3 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/hooks/useErc20Balance.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Token } from '@/src/types/token'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
import { renderHook, waitFor } from '@testing-library/react'
4+
import type { ReactNode } from 'react'
5+
import { createElement } from 'react'
6+
import { zeroAddress } from 'viem'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { useErc20Balance } from './useErc20Balance'
9+
10+
const mockReadContract = vi.fn()
11+
12+
vi.mock('wagmi', () => ({
13+
usePublicClient: vi.fn(() => ({
14+
readContract: mockReadContract,
15+
})),
16+
}))
17+
18+
vi.mock('@/src/env', () => ({
19+
env: { PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase() },
20+
}))
21+
22+
const wrapper = ({ children }: { children: ReactNode }) =>
23+
createElement(
24+
QueryClientProvider,
25+
{ client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
26+
children,
27+
)
28+
29+
const mockToken: Token = {
30+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
31+
chainId: 1,
32+
decimals: 6,
33+
name: 'USD Coin',
34+
symbol: 'USDC',
35+
}
36+
37+
const walletAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as `0x${string}`
38+
39+
describe('useErc20Balance', () => {
40+
beforeEach(() => {
41+
mockReadContract.mockClear()
42+
})
43+
44+
it('returns undefined balance when address is missing', () => {
45+
const { result } = renderHook(() => useErc20Balance({ token: mockToken }), { wrapper })
46+
expect(result.current.balance).toBeUndefined()
47+
expect(result.current.isLoadingBalance).toBe(false)
48+
})
49+
50+
it('returns undefined balance when token is missing', () => {
51+
const { result } = renderHook(() => useErc20Balance({ address: walletAddress }), { wrapper })
52+
expect(result.current.balance).toBeUndefined()
53+
expect(result.current.isLoadingBalance).toBe(false)
54+
})
55+
56+
it('does not fetch balance for native token address', () => {
57+
const nativeToken: Token = { ...mockToken, address: zeroAddress }
58+
const { result } = renderHook(
59+
() => useErc20Balance({ address: walletAddress, token: nativeToken }),
60+
{ wrapper },
61+
)
62+
expect(mockReadContract).not.toHaveBeenCalled()
63+
expect(result.current.isLoadingBalance).toBe(false)
64+
})
65+
66+
it('returns balance when query resolves', async () => {
67+
mockReadContract.mockResolvedValueOnce(BigInt(1_000_000))
68+
const { result } = renderHook(
69+
() => useErc20Balance({ address: walletAddress, token: mockToken }),
70+
{ wrapper },
71+
)
72+
await waitFor(() => expect(result.current.isLoadingBalance).toBe(false))
73+
expect(result.current.balance).toBe(BigInt(1_000_000))
74+
expect(result.current.balanceError).toBeNull()
75+
})
76+
77+
it('returns error when query fails', async () => {
78+
mockReadContract.mockRejectedValueOnce(new Error('RPC error'))
79+
const { result } = renderHook(
80+
() => useErc20Balance({ address: walletAddress, token: mockToken }),
81+
{ wrapper },
82+
)
83+
await waitFor(() => expect(result.current.balanceError).toBeTruthy())
84+
expect(result.current.balance).toBeUndefined()
85+
})
86+
})

src/hooks/useTokenLists.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Token } from '@/src/types/token'
2+
import tokenListsCache, { updateTokenListsCache } from '@/src/utils/tokenListsCache'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { renderHook } from '@testing-library/react'
5+
import { createElement } from 'react'
6+
import type { ReactNode } from 'react'
7+
import { zeroAddress } from 'viem'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
vi.mock('@/src/utils/tokenListsCache', () => {
11+
const cache = { tokens: [] as Token[], tokensByChainId: {} as Record<number, Token[]> }
12+
return {
13+
default: cache,
14+
updateTokenListsCache: vi.fn((map: typeof cache) => {
15+
cache.tokens = map.tokens
16+
cache.tokensByChainId = map.tokensByChainId
17+
}),
18+
addTokenToTokenList: vi.fn(),
19+
}
20+
})
21+
22+
vi.mock('@/src/env', () => ({
23+
env: {
24+
PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress.toLowerCase(),
25+
PUBLIC_USE_DEFAULT_TOKENS: false,
26+
},
27+
}))
28+
29+
vi.mock('@/src/constants/tokenLists', () => ({
30+
tokenLists: {},
31+
}))
32+
33+
vi.mock('@tanstack/react-query', async (importActual) => {
34+
const actual = await importActual<typeof import('@tanstack/react-query')>()
35+
return { ...actual, useSuspenseQueries: vi.fn() }
36+
})
37+
38+
import * as tanstackQuery from '@tanstack/react-query'
39+
import { useTokenLists } from './useTokenLists'
40+
41+
const mockToken1: Token = {
42+
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
43+
chainId: 1,
44+
decimals: 6,
45+
name: 'USD Coin',
46+
symbol: 'USDC',
47+
}
48+
const mockToken2: Token = {
49+
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
50+
chainId: 1,
51+
decimals: 6,
52+
name: 'Tether USD',
53+
symbol: 'USDT',
54+
}
55+
56+
const mockSuspenseQueryResult = (tokens: Token[]) => ({
57+
data: { name: 'Mock List', timestamp: '', version: { major: 1, minor: 0, patch: 0 }, tokens },
58+
isLoading: false,
59+
isSuccess: true,
60+
error: null,
61+
})
62+
63+
const wrapper = ({ children }: { children: ReactNode }) =>
64+
createElement(QueryClientProvider, { client: new QueryClient() }, children)
65+
66+
beforeEach(() => {
67+
// Reset cache between tests
68+
tokenListsCache.tokens = []
69+
tokenListsCache.tokensByChainId = {}
70+
vi.mocked(updateTokenListsCache).mockImplementation((map) => {
71+
tokenListsCache.tokens = map.tokens
72+
tokenListsCache.tokensByChainId = map.tokensByChainId
73+
})
74+
})
75+
76+
describe('useTokenLists', () => {
77+
it('returns tokens and tokensByChainId', () => {
78+
vi.mocked(tanstackQuery.useSuspenseQueries).mockReturnValueOnce(
79+
// biome-ignore lint/suspicious/noExplicitAny: mocking overloaded hook return type
80+
{ tokens: [mockToken1], tokensByChainId: { 1: [mockToken1] } } as any,
81+
)
82+
83+
const { result } = renderHook(() => useTokenLists(), { wrapper })
84+
expect(result.current.tokens).toBeDefined()
85+
expect(result.current.tokensByChainId).toBeDefined()
86+
})
87+
88+
it('deduplicates tokens with the same chainId and address', () => {
89+
// biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
90+
vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => {
91+
const results = [
92+
mockSuspenseQueryResult([mockToken1, mockToken2]),
93+
mockSuspenseQueryResult([{ ...mockToken1 }]), // duplicate
94+
]
95+
return combine(results)
96+
})
97+
98+
const { result } = renderHook(() => useTokenLists(), { wrapper })
99+
const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase())
100+
expect(erc20Tokens).toHaveLength(2)
101+
expect(erc20Tokens.map((t) => t.symbol)).toContain('USDC')
102+
expect(erc20Tokens.map((t) => t.symbol)).toContain('USDT')
103+
})
104+
105+
it('injects a native ETH token for mainnet (chainId 1) tokens', () => {
106+
vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(
107+
// biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
108+
({ combine }: any) => combine([mockSuspenseQueryResult([mockToken1])]),
109+
)
110+
111+
const { result } = renderHook(() => useTokenLists(), { wrapper })
112+
const nativeToken = result.current.tokensByChainId[1]?.[0]
113+
expect(nativeToken?.address).toBe(zeroAddress.toLowerCase())
114+
expect(nativeToken?.symbol).toBe('ETH')
115+
})
116+
117+
it('filters out tokens that fail schema validation', () => {
118+
// biome-ignore lint/suspicious/noExplicitAny: mocking internal combine param
119+
vi.mocked(tanstackQuery.useSuspenseQueries).mockImplementation(({ combine }: any) => {
120+
const invalidToken = {
121+
address: 'not-an-address',
122+
chainId: 1,
123+
name: 'Bad',
124+
symbol: 'BAD',
125+
decimals: 18,
126+
}
127+
// biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid token input
128+
return combine([mockSuspenseQueryResult([mockToken1, invalidToken as any])])
129+
})
130+
131+
const { result } = renderHook(() => useTokenLists(), { wrapper })
132+
const erc20Tokens = result.current.tokens.filter((t) => t.address !== zeroAddress.toLowerCase())
133+
expect(erc20Tokens).toHaveLength(1)
134+
expect(erc20Tokens[0].symbol).toBe('USDC')
135+
})
136+
})

src/hooks/useWeb3Status.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { renderHook } from '@testing-library/react'
2+
import type { Address } from 'viem'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { useWeb3Status, useWeb3StatusConnected } from './useWeb3Status'
5+
6+
const mockDisconnect = vi.fn()
7+
const mockSwitchChain = vi.fn()
8+
9+
vi.mock('wagmi', () => ({
10+
useAccount: vi.fn(() => ({
11+
address: undefined,
12+
chainId: undefined,
13+
isConnected: false,
14+
isConnecting: false,
15+
})),
16+
useChainId: vi.fn(() => 1),
17+
useSwitchChain: vi.fn(() => ({ isPending: false, switchChain: mockSwitchChain })),
18+
usePublicClient: vi.fn(() => undefined),
19+
useWalletClient: vi.fn(() => ({ data: undefined })),
20+
useBalance: vi.fn(() => ({ data: undefined })),
21+
useDisconnect: vi.fn(() => ({ disconnect: mockDisconnect })),
22+
}))
23+
24+
import * as wagmi from 'wagmi'
25+
26+
type MockAccount = ReturnType<typeof wagmi.useAccount>
27+
type MockSwitchChain = ReturnType<typeof wagmi.useSwitchChain>
28+
29+
describe('useWeb3Status', () => {
30+
beforeEach(() => {
31+
mockDisconnect.mockClear()
32+
mockSwitchChain.mockClear()
33+
})
34+
35+
it('returns disconnected state when no wallet connected', () => {
36+
const { result } = renderHook(() => useWeb3Status())
37+
expect(result.current.isWalletConnected).toBe(false)
38+
expect(result.current.address).toBeUndefined()
39+
expect(result.current.walletChainId).toBeUndefined()
40+
})
41+
42+
it('returns connected state with wallet address', () => {
43+
const mock = {
44+
address: '0xabc123' as Address,
45+
chainId: 1,
46+
isConnected: true,
47+
isConnecting: false,
48+
} as unknown as MockAccount
49+
vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
50+
const { result } = renderHook(() => useWeb3Status())
51+
expect(result.current.isWalletConnected).toBe(true)
52+
expect(result.current.address).toBe('0xabc123')
53+
})
54+
55+
it('sets isWalletSynced true when wallet chainId matches app chainId', () => {
56+
const mock = {
57+
address: '0xabc123' as Address,
58+
chainId: 1,
59+
isConnected: true,
60+
isConnecting: false,
61+
} as unknown as MockAccount
62+
vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
63+
vi.mocked(wagmi.useChainId).mockReturnValueOnce(1)
64+
const { result } = renderHook(() => useWeb3Status())
65+
expect(result.current.isWalletSynced).toBe(true)
66+
})
67+
68+
it('sets isWalletSynced false when wallet chainId differs from app chainId', () => {
69+
const mock = {
70+
address: '0xabc123' as Address,
71+
chainId: 137,
72+
isConnected: true,
73+
isConnecting: false,
74+
} as unknown as MockAccount
75+
vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock)
76+
vi.mocked(wagmi.useChainId).mockReturnValueOnce(1)
77+
const { result } = renderHook(() => useWeb3Status())
78+
expect(result.current.isWalletSynced).toBe(false)
79+
})
80+
81+
it('sets switchingChain when useSwitchChain is pending', () => {
82+
const mock = { isPending: true, switchChain: mockSwitchChain } as unknown as MockSwitchChain
83+
vi.mocked(wagmi.useSwitchChain).mockReturnValueOnce(mock)
84+
const { result } = renderHook(() => useWeb3Status())
85+
expect(result.current.switchingChain).toBe(true)
86+
})
87+
88+
it('exposes disconnect function', () => {
89+
const { result } = renderHook(() => useWeb3Status())
90+
result.current.disconnect()
91+
expect(mockDisconnect).toHaveBeenCalled()
92+
})
93+
94+
it('calls switchChain with chainId when switchChain action is invoked', () => {
95+
const { result } = renderHook(() => useWeb3Status())
96+
result.current.switchChain(137)
97+
expect(mockSwitchChain).toHaveBeenCalledWith({ chainId: 137 })
98+
})
99+
100+
it('exposes appChainId from useChainId', () => {
101+
vi.mocked(wagmi.useChainId).mockReturnValueOnce(42161)
102+
const { result } = renderHook(() => useWeb3Status())
103+
expect(result.current.appChainId).toBe(42161)
104+
})
105+
})
106+
107+
describe('useWeb3StatusConnected', () => {
108+
it('throws when wallet is not connected', () => {
109+
expect(() => renderHook(() => useWeb3StatusConnected())).toThrow(
110+
'Use useWeb3StatusConnected only when a wallet is connected',
111+
)
112+
})
113+
114+
it('returns status when wallet is connected', () => {
115+
const mock = {
116+
address: '0xdeadbeef' as Address,
117+
chainId: 1,
118+
isConnected: true,
119+
isConnecting: false,
120+
} as unknown as MockAccount
121+
// useWeb3StatusConnected calls useWeb3Status twice; both calls must see connected state
122+
vi.mocked(wagmi.useAccount).mockReturnValueOnce(mock).mockReturnValueOnce(mock)
123+
const { result } = renderHook(() => useWeb3StatusConnected())
124+
expect(result.current.isWalletConnected).toBe(true)
125+
})
126+
})

0 commit comments

Comments
 (0)