diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..f4bf82d2 --- /dev/null +++ b/.env.test @@ -0,0 +1,7 @@ +# Test environment variables for Vitest +PUBLIC_APP_NAME=dAppBooster Test +PUBLIC_NATIVE_TOKEN_ADDRESS=0x0000000000000000000000000000000000000000 +PUBLIC_WALLETCONNECT_PROJECT_ID=test-project-id +PUBLIC_SUBGRAPHS_API_KEY=test-api-key +PUBLIC_SUBGRAPHS_CHAINS_RESOURCE_IDS=1:test:test-resource-id +PUBLIC_SUBGRAPHS_ENVIRONMENT=production diff --git a/.gitignore b/.gitignore index be85071d..9203123b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.worktrees diff --git a/setupTests.ts b/setupTests.ts index a55b20e6..a9d13f77 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -4,6 +4,20 @@ import { afterEach, expect } from 'vitest' expect.extend(matchers) +// ResizeObserver is not implemented in jsdom but required by @floating-ui (Chakra menus/popovers). +// Use a real class rather than vi.fn() so vi.restoreAllMocks() in test files cannot clear it. +if (typeof globalThis.ResizeObserver === 'undefined') { + class ResizeObserver { + // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment + observe(_target: any) {} + // biome-ignore lint/suspicious/noExplicitAny: stub for jsdom test environment + unobserve(_target: any) {} + disconnect() {} + } + // @ts-expect-error ResizeObserver is not in the Node/jsdom type definitions + globalThis.ResizeObserver = ResizeObserver +} + afterEach(() => { cleanup() }) diff --git a/src/components/pageComponents/NotFound404.test.tsx b/src/components/pageComponents/NotFound404.test.tsx new file mode 100644 index 00000000..6ce87039 --- /dev/null +++ b/src/components/pageComponents/NotFound404.test.tsx @@ -0,0 +1,22 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import NotFound404 from './NotFound404' + +const system = createSystem(defaultConfig) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: vi.fn(() => vi.fn()), +})) + +describe('NotFound404', () => { + it('renders 404 title and message', () => { + render( + + + , + ) + expect(screen.getByText('404 - Not Found')).toBeDefined() + expect(screen.getByRole('button', { name: 'Home' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx new file mode 100644 index 00000000..ab37345f --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/ConnectWallet/index.test.tsx @@ -0,0 +1,17 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import connectWallet from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('ConnectWallet demo', () => { + it('renders the connect wallet button', () => { + render({connectWallet.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx new file mode 100644 index 00000000..2463d875 --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/EnsName/index.test.tsx @@ -0,0 +1,20 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ensName from './index' + +const system = createSystem(defaultConfig) + +vi.mock('wagmi', () => ({ + useEnsName: vi.fn(() => ({ data: undefined, error: undefined, status: 'pending' })), +})) + +describe('EnsName demo', () => { + it('renders the ENS name search interface', () => { + render({ensName.demo}) + expect(screen.getByText('Find ENS name')).toBeDefined() + expect( + screen.getByPlaceholderText('Enter an address or select one from the dropdown'), + ).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx new file mode 100644 index 00000000..90fc9c0f --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/HashHandling/index.test.tsx @@ -0,0 +1,28 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import hashHandling from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + walletChainId: undefined, + })), +})) + +vi.mock('@/src/utils/hash', () => { + const mockFn = vi.fn(() => Promise.resolve(null)) + return { + default: mockFn, + detectHash: mockFn, + } +}) + +describe('HashHandling demo', () => { + it('renders the hash input field', () => { + render({hashHandling.demo}) + expect(screen.getByPlaceholderText(/address|hash/i)).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx new file mode 100644 index 00000000..d673002c --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/SignMessage/index.test.tsx @@ -0,0 +1,27 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import signMessage from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + isWalletSynced: false, + walletChainId: undefined, + appChainId: 11155420, + switchChain: vi.fn(), + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('SignMessage demo', () => { + it('renders connect wallet fallback when wallet not connected', () => { + render({signMessage.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx new file mode 100644 index 00000000..7b93bb7b --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/SwitchNetwork/index.test.tsx @@ -0,0 +1,23 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import switchNetwork from './index' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => ({ + isWalletConnected: false, + })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('SwitchNetwork demo', () => { + it('renders connect wallet button when wallet not connected', () => { + render({switchNetwork.demo}) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx new file mode 100644 index 00000000..ade29b7c --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TokenDropdown/index.test.tsx @@ -0,0 +1,20 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import tokenDropdown from './index' + +const system = createSystem(defaultConfig) + +// Mock the shared component to avoid its deep dependency chain +// (TokenSelect uses withSuspenseAndRetry, useTokenLists, useTokens, etc.) +vi.mock('@/src/components/sharedComponents/TokenDropdown', () => ({ + default: () =>
Token Dropdown
, +})) + +describe('TokenDropdown demo', () => { + it('renders the token dropdown container', () => { + render({tokenDropdown.demo}) + expect(screen.getByText('Search and select a token')).toBeDefined() + expect(screen.getByTestId('token-dropdown-mock')).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx new file mode 100644 index 00000000..9e86b8b7 --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TokenInput/index.test.tsx @@ -0,0 +1,49 @@ +import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import tokenInput from './index' + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => createMockWeb3Status()), +})) + +vi.mock('@/src/hooks/useTokenLists', () => ({ + useTokenLists: vi.fn(() => ({ + tokens: [], + tokensByChainId: {}, + tokensByAddress: {}, + tokensBySymbol: {}, + })), +})) + +vi.mock('@/src/hooks/useTokenSearch', () => ({ + useTokenSearch: vi.fn(() => ({ + searchResult: [], + })), +})) + +vi.mock('@/src/components/sharedComponents/TokenInput/useTokenInput', () => ({ + useTokenInput: vi.fn(() => ({ + amount: 0n, + setAmount: vi.fn(), + amountError: null, + setAmountError: vi.fn(), + balance: 0n, + balanceError: null, + isLoadingBalance: false, + selectedToken: undefined, + setTokenSelected: vi.fn(), + })), +})) + +describe('TokenInput demo', () => { + it('renders the token input container', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderWithProviders( + {tokenInput.demo}, + ) + // The mode dropdown should be visible + expect(screen.getByText('Single token')).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx new file mode 100644 index 00000000..1d7d3be8 --- /dev/null +++ b/src/components/pageComponents/home/Examples/demos/TransactionButton/index.test.tsx @@ -0,0 +1,19 @@ +import { createMockWeb3Status, renderWithProviders } from '@/src/test-utils' +import { screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import transactionButton from './index' + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(() => createMockWeb3Status({ appChainId: 11155420 })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +describe('TransactionButton demo', () => { + it('renders connect wallet fallback when wallet not connected', () => { + renderWithProviders(transactionButton.demo) + expect(screen.getByRole('button', { name: 'Connect Wallet' })).toBeDefined() + }) +}) diff --git a/src/components/pageComponents/home/index.test.tsx b/src/components/pageComponents/home/index.test.tsx new file mode 100644 index 00000000..d498d8a1 --- /dev/null +++ b/src/components/pageComponents/home/index.test.tsx @@ -0,0 +1,21 @@ +import { renderWithProviders } from '@/src/test-utils' +import { screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { Home } from './index' + +// Mock sub-components that pull in Web3 dependencies to keep this a pure structural test +vi.mock('@/src/components/pageComponents/home/Examples', () => ({ + default: () =>
Examples
, +})) + +vi.mock('@/src/components/pageComponents/home/Welcome', () => ({ + default: () =>
Welcome
, +})) + +describe('Home', () => { + it('renders Welcome and Examples sections', () => { + renderWithProviders() + expect(screen.getByTestId('welcome')).toBeDefined() + expect(screen.getByTestId('examples')).toBeDefined() + }) +}) diff --git a/src/components/sharedComponents/BigNumberInput.test.tsx b/src/components/sharedComponents/BigNumberInput.test.tsx index 7554dc5a..03588853 100644 --- a/src/components/sharedComponents/BigNumberInput.test.tsx +++ b/src/components/sharedComponents/BigNumberInput.test.tsx @@ -1,24 +1,126 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import userEvent from '@testing-library/user-event' +import type { ComponentProps } from 'react' +import { maxUint256 } from 'viem' +import { describe, expect, it, vi } from 'vitest' import { BigNumberInput } from './BigNumberInput' const system = createSystem(defaultConfig) +function renderInput( + props: Partial> & { + onChange?: (v: bigint) => void + } = {}, +) { + const onChange = props.onChange ?? vi.fn() + const { container } = render( + + + , + ) + return { input: screen.getByRole('textbox') as HTMLInputElement, onChange, container } +} + describe('BigNumberInput', () => { it('renders without crashing', () => { - render( - - {}} - /> - , - ) - - const input = screen.getByRole('textbox') + const { input } = renderInput() expect(input).not.toBeNull() expect(input.tagName).toBe('INPUT') }) + + it('renders with default placeholder', () => { + const { input } = renderInput() + expect(input.placeholder).toBe('0.00') + }) + + it('renders with custom placeholder', () => { + const { input } = renderInput({ placeholder: 'Amount' }) + expect(input.placeholder).toBe('Amount') + }) + + it('renders as disabled when disabled prop is set', () => { + const { input } = renderInput({ disabled: true }) + expect(input.disabled).toBe(true) + }) + + it('calls onChange with BigInt(0) when input is cleared after typing', async () => { + const onChange = vi.fn() + renderInput({ decimals: 0, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '5') + await userEvent.clear(input) + expect(onChange).toHaveBeenLastCalledWith(BigInt(0)) + }) + + it('calls onChange with the parsed bigint when a valid value is typed', async () => { + const onChange = vi.fn() + renderInput({ decimals: 6, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '1.5') + // 1.5 * 10^6 = 1500000 + expect(onChange).toHaveBeenLastCalledWith(BigInt(1_500_000)) + }) + + it('calls onError when value exceeds max', async () => { + const onChange = vi.fn() + const onError = vi.fn() + renderInput({ + decimals: 0, + max: BigInt(100), + onChange, + onError, + }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '200') + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '200' })) + }) + + it('calls onError when value is below min', async () => { + const onChange = vi.fn() + const onError = vi.fn() + renderInput({ + decimals: 0, + min: BigInt(10), + onChange, + onError, + }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '5') + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ value: '5' })) + }) + + it('does not call onError for valid values', async () => { + const onError = vi.fn() + renderInput({ decimals: 0, max: BigInt(100), onError }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '50') + // onError is only called on invalid values; valid value should not trigger it + expect(onError).not.toHaveBeenCalled() + }) + + it('ignores the extra digit when input has too many decimals', async () => { + const onChange = vi.fn() + renderInput({ decimals: 2, onChange }) + const input = screen.getByRole('textbox') + await userEvent.type(input, '1.123') + // '1.12' is valid (2 decimals), '1.123' has 3 and is ignored + // Last valid call should be for 1.12 = 112n (2 decimals) + const lastCall = onChange.mock.calls.at(-1)?.[0] + expect(lastCall).toBe(BigInt(112)) + }) + + it('does not call onError when value is within max constraint', async () => { + const onError = vi.fn() + renderInput({ decimals: 0, max: maxUint256, onError }) + // Large but valid number — well within maxUint256, so no error + const input = screen.getByRole('textbox') + await userEvent.type(input, '9999999') + expect(onError).not.toHaveBeenCalled() + }) }) diff --git a/src/components/sharedComponents/HashInput.test.tsx b/src/components/sharedComponents/HashInput.test.tsx index 72f11389..34a8c3df 100644 --- a/src/components/sharedComponents/HashInput.test.tsx +++ b/src/components/sharedComponents/HashInput.test.tsx @@ -1,28 +1,118 @@ import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ComponentProps } from 'react' import { mainnet } from 'viem/chains' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import HashInput from './HashInput' const system = createSystem(defaultConfig) +const detectHashMock = vi.fn().mockResolvedValue({ type: 'EOA', data: '0xabc' }) vi.mock('@/src/utils/hash', () => ({ - default: vi.fn().mockResolvedValue(null), + default: (...args: unknown[]) => detectHashMock(...args), })) +function renderHashInput(props: Partial> = {}) { + const onSearch = props.onSearch ?? vi.fn() + render( + + + , + ) + return { input: screen.getByTestId('hash-input') as HTMLInputElement, onSearch } +} + describe('HashInput', () => { + beforeEach(() => { + detectHashMock.mockClear() + detectHashMock.mockResolvedValue({ type: 'EOA', data: '0xabc' }) + }) + it('renders without crashing', () => { + const { input } = renderHashInput() + expect(input).not.toBeNull() + expect(input.tagName).toBe('INPUT') + }) + + it('renders with placeholder prop', () => { + renderHashInput({ placeholder: 'Enter address or hash' }) + expect(screen.getByPlaceholderText('Enter address or hash')).toBeDefined() + }) + + it('reflects typed value in the input', async () => { + const { input } = renderHashInput() + await userEvent.type(input, 'test.eth') + expect(input.value).toBe('test.eth') + }) + + it('renders with initial value', () => { + renderHashInput({ value: '0xInitial' }) + const input = screen.getByTestId('hash-input') as HTMLInputElement + expect(input.value).toBe('0xInitial') + }) + + it('calls onSearch with null when input is cleared', async () => { + const onSearch = vi.fn() + // debounceTime: 0 to avoid relying on real timers for non-debounce behavior + const { input } = renderHashInput({ onSearch, debounceTime: 0 }) + await userEvent.type(input, 'abc') + await userEvent.clear(input) + await waitFor(() => { + expect(onSearch).toHaveBeenLastCalledWith(null) + }) + }) + + it('calls onSearch with the detected result', async () => { + // debounceTime: 0 keeps the test fast; this covers the callback wiring, + // not the debounce delay itself (fake timers + waitFor + userEvent conflict in jsdom) + const onSearch = vi.fn() + detectHashMock.mockResolvedValueOnce({ type: 'ENS', data: '0x123' }) + renderHashInput({ onSearch, debounceTime: 0 }) + const input = screen.getByTestId('hash-input') + await userEvent.type(input, 'vitalik.eth') + await waitFor(() => { + expect(onSearch).toHaveBeenCalledWith({ type: 'ENS', data: '0x123' }) + }) + }) + + it('calls onLoading(true) then onLoading(false) during search', async () => { + const onLoading = vi.fn() + let resolveDetect!: (v: unknown) => void + detectHashMock.mockImplementationOnce( + () => + new Promise((res) => { + resolveDetect = res + }), + ) + renderHashInput({ onLoading, debounceTime: 0 }) + const input = screen.getByTestId('hash-input') + await userEvent.type(input, 'q') + await waitFor(() => expect(onLoading).toHaveBeenCalledWith(true)) + resolveDetect({ type: null, data: null }) + await waitFor(() => expect(onLoading).toHaveBeenCalledWith(false)) + }) + + it('uses custom renderInput when provided', () => { render( {}} + onSearch={vi.fn()} + renderInput={(props) => ( + + )} /> , ) - - const input = screen.getByTestId('hash-input') - expect(input).not.toBeNull() - expect(input.tagName).toBe('INPUT') + expect(screen.getByTestId('custom-input')).toBeDefined() }) }) diff --git a/src/components/sharedComponents/SwitchNetwork.test.tsx b/src/components/sharedComponents/SwitchNetwork.test.tsx new file mode 100644 index 00000000..edbe1408 --- /dev/null +++ b/src/components/sharedComponents/SwitchNetwork.test.tsx @@ -0,0 +1,115 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SwitchNetwork, { type Networks } from './SwitchNetwork' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('wagmi', () => ({ + useSwitchChain: vi.fn(), +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' +import * as wagmiModule from 'wagmi' + +const mockNetworks: Networks = [ + { id: 1, label: 'Ethereum', icon: ETH }, + { id: 137, label: 'Polygon', icon: MATIC }, +] + +function defaultWeb3Status(overrides = {}) { + return { + isWalletConnected: true, + walletChainId: undefined as number | undefined, + walletClient: undefined, + ...overrides, + } +} + +function defaultSwitchChain() { + return { + chains: [ + { id: 1, name: 'Ethereum' }, + { id: 137, name: 'Polygon' }, + ], + switchChain: vi.fn(), + } +} + +function renderSwitchNetwork(networks = mockNetworks) { + return render( + + + , + ) +} + +describe('SwitchNetwork', () => { + it('shows "Select a network" when wallet chain does not match any network', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ walletChainId: 999 }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + expect(screen.getByText('Select a network')).toBeDefined() + }) + + it('shows current network label when wallet is on a listed chain', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ walletChainId: 1 }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + expect(screen.getByText('Ethereum')).toBeDefined() + }) + + it('trigger button is disabled when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ isWalletConnected: false }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + const button = screen.getByRole('button') + expect(button).toBeDefined() + expect(button.hasAttribute('disabled') || button.getAttribute('data-disabled') !== null).toBe( + true, + ) + }) + + it('shows all network options in the menu after opening it', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultWeb3Status({ isWalletConnected: true }) as any, + ) + vi.mocked(wagmiModule.useSwitchChain).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + defaultSwitchChain() as any, + ) + renderSwitchNetwork() + + // Open the menu by clicking the trigger + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + + await waitFor(() => { + expect(screen.getByText('Ethereum')).toBeDefined() + expect(screen.getByText('Polygon')).toBeDefined() + }) + }) +}) diff --git a/src/components/sharedComponents/TokenLogo.test.tsx b/src/components/sharedComponents/TokenLogo.test.tsx new file mode 100644 index 00000000..b71166ed --- /dev/null +++ b/src/components/sharedComponents/TokenLogo.test.tsx @@ -0,0 +1,75 @@ +import type { Token } from '@/src/types/token' +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import TokenLogo from './TokenLogo' + +const system = createSystem(defaultConfig) + +const mockToken: Token = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + logoURI: 'https://example.com/usdc.png', +} + +const tokenWithoutLogo: Token = { + ...mockToken, + logoURI: undefined, +} + +function renderTokenLogo(token: Token, size?: number) { + return render( + + + , + ) +} + +describe('TokenLogo', () => { + it('renders an img with correct src when logoURI is present', () => { + renderTokenLogo(mockToken) + const img = screen.getByRole('img') + expect(img).toBeDefined() + expect(img.getAttribute('src')).toBe(mockToken.logoURI) + }) + + it('renders an img with correct alt text', () => { + renderTokenLogo(mockToken) + const img = screen.getByAltText('USD Coin') + expect(img).toBeDefined() + }) + + it('applies correct width and height from size prop', () => { + renderTokenLogo(mockToken, 48) + const img = screen.getByRole('img') + expect(img.getAttribute('width')).toBe('48') + expect(img.getAttribute('height')).toBe('48') + }) + + it('renders placeholder with token symbol initial when no logoURI', () => { + renderTokenLogo(tokenWithoutLogo) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('U')).toBeDefined() // first char of 'USDC' + }) + + it('renders placeholder when img fails to load', () => { + renderTokenLogo(mockToken) + const img = screen.getByRole('img') + fireEvent.error(img) + expect(screen.queryByRole('img')).toBeNull() + expect(screen.getByText('U')).toBeDefined() + }) + + it('converts ipfs:// URLs to https://ipfs.io gateway URLs', () => { + const ipfsToken: Token = { ...mockToken, logoURI: 'ipfs://QmHash123' } + renderTokenLogo(ipfsToken) + const img = screen.getByRole('img') + expect(img.getAttribute('src')).toBe('https://ipfs.io/ipfs/QmHash123') + }) +}) diff --git a/src/components/sharedComponents/TransactionButton.test.tsx b/src/components/sharedComponents/TransactionButton.test.tsx new file mode 100644 index 00000000..74e4686d --- /dev/null +++ b/src/components/sharedComponents/TransactionButton.test.tsx @@ -0,0 +1,146 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import TransactionButton from './TransactionButton' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('@/src/providers/TransactionNotificationProvider', () => ({ + useTransactionNotification: vi.fn(() => ({ + watchTx: vi.fn(), + watchHash: vi.fn(), + watchSignature: vi.fn(), + })), +})) + +vi.mock('wagmi', () => ({ + useWaitForTransactionReceipt: vi.fn(() => ({ data: undefined })), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' +import * as wagmiModule from 'wagmi' + +// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) +const OP_SEPOLIA_ID = 11155420 as const + +function connectedStatus() { + return { + isWalletConnected: true, + isWalletSynced: true, + walletChainId: OP_SEPOLIA_ID, + appChainId: OP_SEPOLIA_ID, + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + balance: undefined, + connectingWallet: false, + switchingChain: false, + walletClient: undefined, + readOnlyClient: undefined, + switchChain: vi.fn(), + disconnect: vi.fn(), + } +} + +// biome-ignore lint/suspicious/noExplicitAny: test helper accepts flexible props +function renderButton(props: any = {}) { + return render( + + Promise.resolve('0x1' as `0x${string}`)} + {...props} + /> + , + ) +} + +describe('TransactionButton', () => { + it('renders fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ + ...connectedStatus(), + isWalletConnected: false, + isWalletSynced: false, + }) + renderButton() + expect(screen.getByText('Connect Wallet')).toBeDefined() + }) + + it('renders switch chain button when wallet is on wrong chain', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue({ + ...connectedStatus(), + isWalletSynced: false, + walletChainId: 1, + }) + renderButton() + expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') + }) + + it('renders with default label when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + renderButton() + expect(screen.getByText('Send Transaction')).toBeDefined() + }) + + it('renders with custom children label', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + renderButton({ children: 'Deposit ETH' }) + expect(screen.getByText('Deposit ETH')).toBeDefined() + }) + + it('shows labelSending while transaction is pending', async () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockReturnValue({ + data: undefined, + } as ReturnType) + + const neverResolves = () => new Promise<`0x${string}`>(() => {}) + renderButton({ transaction: neverResolves, labelSending: 'Processing...' }) + + expect(screen.getByRole('button').textContent).not.toContain('Processing...') + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByRole('button').textContent).toContain('Processing...') + }) + }) + + it('calls onMined when receipt becomes available', async () => { + // biome-ignore lint/suspicious/noExplicitAny: mock receipt shape + const mockReceipt = { status: 'success', transactionHash: '0x1' } as any + const onMined = vi.fn() + + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue(connectedStatus()) + // Only return a receipt when called with the matching hash so the mock + // doesn't fire prematurely before the transaction is submitted. + vi.mocked(wagmiModule.useWaitForTransactionReceipt).mockImplementation( + (config) => + ({ + data: config?.hash === '0x1' ? mockReceipt : undefined, + }) as ReturnType, + ) + + renderButton({ + transaction: () => Promise.resolve('0x1' as `0x${string}`), + onMined, + }) + + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(onMined).toHaveBeenCalledWith(mockReceipt) + }) + }) +}) diff --git a/src/components/sharedComponents/WalletStatusVerifier.test.tsx b/src/components/sharedComponents/WalletStatusVerifier.test.tsx new file mode 100644 index 00000000..bd7d75ed --- /dev/null +++ b/src/components/sharedComponents/WalletStatusVerifier.test.tsx @@ -0,0 +1,121 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { WalletStatusVerifier, withWalletStatusVerifier } from './WalletStatusVerifier' + +const system = createSystem(defaultConfig) + +vi.mock('@/src/hooks/useWeb3Status', () => ({ + useWeb3Status: vi.fn(), +})) + +vi.mock('@/src/providers/Web3Provider', () => ({ + ConnectWalletButton: () => , +})) + +import * as useWeb3StatusModule from '@/src/hooks/useWeb3Status' + +// chains[0] = optimismSepolia (id: 11155420) when PUBLIC_INCLUDE_TESTNETS=true (default) +const OP_SEPOLIA_ID = 11155420 as const + +function connectedSyncedStatus(overrides = {}) { + return { + isWalletConnected: true, + isWalletSynced: true, + walletChainId: OP_SEPOLIA_ID, + appChainId: OP_SEPOLIA_ID, + switchChain: vi.fn(), + disconnect: vi.fn(), + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + balance: undefined, + connectingWallet: false, + switchingChain: false, + walletClient: undefined, + readOnlyClient: undefined, + ...overrides, + } +} + +function wrap(ui: React.ReactElement) { + return render({ui}) +} + +describe('WalletStatusVerifier', () => { + it('renders default ConnectWalletButton fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByText('Connect Wallet')).toBeDefined() + expect(screen.queryByText('Protected Content')).toBeNull() + }) + + it('renders custom fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap( + Custom Fallback}> +
Protected Content
+
, + ) + expect(screen.getByText('Custom Fallback')).toBeDefined() + }) + + it('renders switch chain button when wallet is on wrong chain', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletSynced: false, walletChainId: 1 }) as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByRole('button').textContent?.toLowerCase()).toContain('switch to') + expect(screen.queryByText('Protected Content')).toBeNull() + }) + + it('renders children when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus() as any, + ) + wrap( + +
Protected Content
+
, + ) + expect(screen.getByText('Protected Content')).toBeDefined() + }) +}) + +describe('withWalletStatusVerifier HOC', () => { + const ProtectedComponent = () =>
Protected Component
+ const Wrapped = withWalletStatusVerifier(ProtectedComponent) + + it('renders fallback when wallet not connected', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus({ isWalletConnected: false, isWalletSynced: false }) as any, + ) + wrap() + expect(screen.getByText('Connect Wallet')).toBeDefined() + expect(screen.queryByText('Protected Component')).toBeNull() + }) + + it('renders wrapped component when wallet is connected and synced', () => { + vi.mocked(useWeb3StatusModule.useWeb3Status).mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: partial mock + connectedSyncedStatus() as any, + ) + wrap() + expect(screen.getByText('Protected Component')).toBeDefined() + }) +}) diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 00000000..07c3a923 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,54 @@ +import { zeroAddress } from 'viem' +import { describe, expect, it } from 'vitest' + +// env.ts reads import.meta.env at module load time. +// Vitest loads .env.test automatically for the "test" mode, +// so PUBLIC_APP_NAME, PUBLIC_SUBGRAPHS_*, etc. are set via .env.test. +import { env } from '@/src/env' + +describe('env', () => { + it('exposes PUBLIC_APP_NAME from test env', () => { + expect(env.PUBLIC_APP_NAME).toBe('dAppBooster Test') + }) + + it('reads and normalizes PUBLIC_NATIVE_TOKEN_ADDRESS from env', () => { + // .env.test sets it to the zero address; the schema lowercases the value + expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(zeroAddress.toLowerCase()) + }) + + it('lowercases PUBLIC_NATIVE_TOKEN_ADDRESS', () => { + expect(env.PUBLIC_NATIVE_TOKEN_ADDRESS).toBe(env.PUBLIC_NATIVE_TOKEN_ADDRESS.toLowerCase()) + }) + + it('defaults PUBLIC_ENABLE_PORTO to true', () => { + expect(env.PUBLIC_ENABLE_PORTO).toBe(true) + }) + + it('defaults PUBLIC_USE_DEFAULT_TOKENS to true', () => { + expect(env.PUBLIC_USE_DEFAULT_TOKENS).toBe(true) + }) + + it('defaults PUBLIC_INCLUDE_TESTNETS to true', () => { + expect(env.PUBLIC_INCLUDE_TESTNETS).toBe(true) + }) + + it('reads PUBLIC_SUBGRAPHS_ENVIRONMENT from test env', () => { + // .env.test sets it to 'production'; to test the schema default use vi.resetModules() + expect(env.PUBLIC_SUBGRAPHS_ENVIRONMENT).toBe('production') + }) + + it('exposes PUBLIC_SUBGRAPHS_API_KEY from test env', () => { + expect(env.PUBLIC_SUBGRAPHS_API_KEY).toBe('test-api-key') + }) + + it('exposes PUBLIC_WALLETCONNECT_PROJECT_ID from test env', () => { + // .env.test sets it to 'test-project-id' + expect(env.PUBLIC_WALLETCONNECT_PROJECT_ID).toBe('test-project-id') + }) + + it('optional RPC vars are undefined when not set in test env', () => { + // None of the RPC vars are set in .env.test + expect(env.PUBLIC_RPC_MAINNET).toBeUndefined() + expect(env.PUBLIC_RPC_SEPOLIA).toBeUndefined() + }) +}) 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) + }) +}) diff --git a/src/test-utils.tsx b/src/test-utils.tsx new file mode 100644 index 00000000..d60fd802 --- /dev/null +++ b/src/test-utils.tsx @@ -0,0 +1,57 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import type { Chain } from 'viem' + +const system = createSystem(defaultConfig) + +/** + * Wraps a component in the providers needed for most tests. + */ +export function renderWithProviders(ui: ReactNode) { + return render({ui}) +} + +/** + * Returns a minimal mock of the useWeb3Status return value. + * Pass overrides to test specific states. + */ +export function createMockWeb3Status(overrides?: Partial>) { + return { ..._mockShape(), ...overrides } +} + +function _mockShape() { + return { + // AppWeb3Status + readOnlyClient: undefined, + appChainId: 1 as number, + // WalletWeb3Status + address: undefined as `0x${string}` | undefined, + balance: undefined, + connectingWallet: false, + switchingChain: false, + isWalletConnected: false, + walletClient: undefined, + isWalletSynced: false, + walletChainId: undefined as number | undefined, + // Web3Actions + disconnect: () => {}, + switchChain: (_chainId?: number) => {}, + } +} + +/** + * Returns a minimal valid viem Chain object for tests. + */ +export function createMockChain(overrides?: Partial): Chain { + return { + id: 1, + name: 'Mock Chain', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://mock.rpc.url'] } }, + blockExplorers: { + default: { name: 'MockExplorer', url: 'https://mock.explorer.url' }, + }, + ...overrides, + } as Chain +} diff --git a/src/utils/address.test.ts b/src/utils/address.test.ts new file mode 100644 index 00000000..49c079a9 --- /dev/null +++ b/src/utils/address.test.ts @@ -0,0 +1,26 @@ +import { zeroAddress } from 'viem' +import { describe, expect, it, vi } from 'vitest' + +// Mock env before importing isNativeToken so the module sees the mock +vi.mock('@/src/env', () => ({ + env: { + PUBLIC_NATIVE_TOKEN_ADDRESS: zeroAddress, + }, +})) + +import { isNativeToken } from './address' + +describe('isNativeToken', () => { + it('returns true for the zero address (default native token)', () => { + expect(isNativeToken(zeroAddress)).toBe(true) + }) + + it('returns true for the zero address string literal', () => { + // zeroAddress is already lowercase; the literal string is identical — testing the exact value + expect(isNativeToken('0x0000000000000000000000000000000000000000')).toBe(true) + }) + + it('returns false for a regular ERC20 contract address', () => { + expect(isNativeToken('0x71C7656EC7ab88b098defB751B7401B5f6d8976F')).toBe(false) + }) +}) diff --git a/src/utils/getExplorerLink.test.ts b/src/utils/getExplorerLink.test.ts new file mode 100644 index 00000000..c7ca4fad --- /dev/null +++ b/src/utils/getExplorerLink.test.ts @@ -0,0 +1,52 @@ +import { createMockChain } from '@/src/test-utils' +import type { Chain } from 'viem' +import { describe, expect, it } from 'vitest' +import { getExplorerLink } from './getExplorerLink' + +const chain = createMockChain() +// A valid address (40 hex chars after 0x) +const address = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F' as const +// A valid tx hash (64 hex chars after 0x) +const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19' as const + +describe('getExplorerLink', () => { + it('returns address URL using chain block explorer', () => { + const url = getExplorerLink({ chain, hashOrAddress: address }) + expect(url).toBe(`https://mock.explorer.url/address/${address}`) + }) + + it('returns tx URL using chain block explorer for a hash', () => { + const url = getExplorerLink({ chain, hashOrAddress: txHash }) + expect(url).toBe(`https://mock.explorer.url/tx/${txHash}`) + }) + + it('uses custom explorerUrl for an address', () => { + const explorerUrl = 'https://custom.explorer.io' + const url = getExplorerLink({ chain, hashOrAddress: address, explorerUrl }) + expect(url).toBe(`${explorerUrl}/address/${address}`) + }) + + it('uses custom explorerUrl for a tx hash', () => { + const explorerUrl = 'https://custom.explorer.io' + const url = getExplorerLink({ chain, hashOrAddress: txHash, explorerUrl }) + expect(url).toBe(`${explorerUrl}/tx/${txHash}`) + }) + + it('throws for an invalid hash or address', () => { + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: intentionally testing invalid input + getExplorerLink({ chain, hashOrAddress: 'not-valid' as any }), + ).toThrow('Invalid hash or address') + }) + + it('works with a chain that has no default block explorer (explorerUrl provided)', () => { + const chainWithoutExplorer: Chain = { ...chain, blockExplorers: undefined } + const explorerUrl = 'https://fallback.explorer.io' + const url = getExplorerLink({ + chain: chainWithoutExplorer, + hashOrAddress: address, + explorerUrl, + }) + expect(url).toBe(`${explorerUrl}/address/${address}`) + }) +}) diff --git a/src/utils/strings.test.ts b/src/utils/strings.test.ts new file mode 100644 index 00000000..e3e8d775 --- /dev/null +++ b/src/utils/strings.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { getTruncatedHash, truncateStringInTheMiddle } from './strings' + +describe('truncateStringInTheMiddle', () => { + it('truncates a long string keeping start and end', () => { + const result = truncateStringInTheMiddle('0x1234567890abcdef1234567890abcdef12345678', 8, 6) + expect(result).toBe('0x123456...345678') + }) + + it('returns the original string when it fits within start + end length', () => { + const result = truncateStringInTheMiddle('short', 4, 4) + expect(result).toBe('short') + }) + + it('returns the original string when length equals start + end exactly', () => { + const result = truncateStringInTheMiddle('1234567890', 5, 5) + expect(result).toBe('1234567890') + }) + + it('truncates when length exceeds start + end by one', () => { + const result = truncateStringInTheMiddle('12345678901', 5, 5) + expect(result).toBe('12345...78901') + }) + + it('handles empty string', () => { + const result = truncateStringInTheMiddle('', 4, 4) + expect(result).toBe('') + }) + + it('handles asymmetric start and end positions', () => { + const result = truncateStringInTheMiddle('abcdefghijklmnop', 3, 7) + expect(result).toBe('abc...jklmnop') + }) + + it('handles start position of 0', () => { + const result = truncateStringInTheMiddle('abcdefghij', 0, 3) + expect(result).toBe('...hij') + }) +}) + +describe('getTruncatedHash', () => { + const address = '0x1234567890abcdef1234567890abcdef12345678' + const txHash = '0xd85ef8c70dc31a4f8d5bf0331e1eac886935905f15d32e71b348df745cd38e19' + + it('truncates with default length of 6', () => { + const result = getTruncatedHash(address) + // 0x + 6 chars ... last 6 chars + expect(result).toBe('0x123456...345678') + }) + + it('truncates with custom length', () => { + const result = getTruncatedHash(address, 4) + // 0x + 4 chars ... last 4 chars + expect(result).toBe('0x1234...5678') + }) + + it('truncates a transaction hash', () => { + const result = getTruncatedHash(txHash, 6) + expect(result).toBe('0xd85ef8...d38e19') + }) + + it('clamps length to minimum of 1', () => { + const result = getTruncatedHash(address, 0) + // length clamped to 1: 0x + 1 char ... last 1 char + expect(result).toBe('0x1...8') + }) + + it('clamps length to maximum of 16', () => { + const result = getTruncatedHash(address, 100) + // address = '0x1234567890abcdef1234567890abcdef12345678' (42 chars) + // length clamped to 16: slice(0, 18) = '0x1234567890abcdef', slice(42-16, 42) = '90abcdef12345678' + expect(result).toBe('0x1234567890abcdef...90abcdef12345678') + }) + + it('handles negative length by clamping to 1', () => { + const result = getTruncatedHash(address, -5) + expect(result).toBe('0x1...8') + }) +}) diff --git a/src/utils/suspenseWrapper.test.tsx b/src/utils/suspenseWrapper.test.tsx new file mode 100644 index 00000000..bd529c57 --- /dev/null +++ b/src/utils/suspenseWrapper.test.tsx @@ -0,0 +1,118 @@ +import { ChakraProvider, createSystem, defaultConfig } from '@chakra-ui/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { withSuspense, withSuspenseAndRetry } from './suspenseWrapper' + +const system = createSystem(defaultConfig) + +// Silence expected React error boundary console.errors. +// Only restore this specific spy — vi.restoreAllMocks() would also wipe global +// polyfills set up in setupTests.ts (e.g. ResizeObserver). +let consoleErrorSpy: ReturnType +beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(() => { + consoleErrorSpy.mockRestore() +}) + +function wrap(ui: ReactNode, withQuery = false) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const inner = withQuery ? ( + {ui} + ) : ( + ui + ) + return render({inner}) +} + +const NormalComponent = () =>
Normal Content
+const SuspendedComponent = () => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new Promise(() => {}) +} + +function makeErrorComponent(message: string) { + return function ErrorComponent() { + throw new Error(message) + } +} + +describe('withSuspense', () => { + it('renders the wrapped component when no error or suspension', () => { + const Wrapped = withSuspense(NormalComponent) + wrap() + expect(screen.getByText('Normal Content')).toBeDefined() + }) + + it('shows custom suspense fallback while component is suspended', () => { + const Wrapped = withSuspense(SuspendedComponent) + wrap(Loading...} />) + expect(screen.getByText('Loading...')).toBeDefined() + }) + + it('shows default error message when component throws', async () => { + const Wrapped = withSuspense(makeErrorComponent('boom')) + wrap() + await waitFor(() => { + expect(screen.getByText('Something went wrong...')).toBeDefined() + }) + }) + + it('shows custom errorFallback text when provided', async () => { + const Wrapped = withSuspense(makeErrorComponent('boom')) + wrap() + await waitFor(() => { + expect(screen.getByText('Custom error text')).toBeDefined() + }) + }) +}) + +describe('withSuspenseAndRetry', () => { + it('renders the wrapped component when no error or suspension', () => { + const Wrapped = withSuspenseAndRetry(NormalComponent) + wrap(, true) + expect(screen.getByText('Normal Content')).toBeDefined() + }) + + it('shows custom suspense fallback while component is suspended', () => { + const Wrapped = withSuspenseAndRetry(SuspendedComponent) + wrap(Loading...} />, true) + expect(screen.getByText('Loading...')).toBeDefined() + }) + + it('shows error message and Try Again button when component throws', async () => { + const Wrapped = withSuspenseAndRetry(makeErrorComponent('Fetch failed')) + wrap(, true) + await waitFor(() => { + expect(screen.getByText('Fetch failed')).toBeDefined() + expect(screen.getByText('Try Again')).toBeDefined() + }) + }) + + it('resets error boundary when Try Again is clicked', async () => { + // Use an external flag so React 19 retries also throw (React retries after first throw + // before giving up to the error boundary, which would reset renderCount-based approaches) + const state = { shouldThrow: true } + const RecoveryComponent = () => { + if (state.shouldThrow) throw new Error('Persistent error') + return
Recovered
+ } + + const Wrapped = withSuspenseAndRetry(RecoveryComponent) + wrap(, true) + + await waitFor(() => { + expect(screen.getByText('Persistent error')).toBeDefined() + }) + + state.shouldThrow = false + fireEvent.click(screen.getByText('Try Again')) + + await waitFor(() => { + expect(screen.getByText('Recovered')).toBeDefined() + }) + }) +})