-
Notifications
You must be signed in to change notification settings - Fork 4
test: add utility and env test coverage (Tier 1) #415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/ai-integration
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(<ChakraProvider value={system}>{ui}</ChakraProvider>) | ||
| } | ||
|
|
||
| /** | ||
| * Returns a minimal mock of the useWeb3Status return value. | ||
| * Pass overrides to test specific states. | ||
| */ | ||
| export function createMockWeb3Status(overrides?: Partial<ReturnType<typeof _mockShape>>) { | ||
| 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) => {}, | ||
| } | ||
|
Comment on lines
+15
to
+40
|
||
| } | ||
|
|
||
| /** | ||
| * Returns a minimal valid viem Chain object for tests. | ||
| */ | ||
| export function createMockChain(overrides?: Partial<Chain>): 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 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.toLowerCase(), | ||
| }, | ||
| })) | ||
|
|
||
| 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 in lowercase', () => { | ||
| expect(isNativeToken(zeroAddress.toLowerCase())).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) | ||
| }) | ||
|
|
||
| it('comparison is case-insensitive', () => { | ||
| // Both upper and lower case should match the native token (zero address) | ||
| expect(isNativeToken('0X0000000000000000000000000000000000000000')).toBe(true) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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}`) | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
|
Comment on lines
+18
to
+21
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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}`) | ||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| }) | |
| }) | |
| it('throws when chain has no default block explorer and no explorerUrl is provided', () => { | |
| const chainWithoutExplorer: Chain = { ...chain, blockExplorers: undefined } | |
| expect(() => | |
| getExplorerLink({ | |
| chain: chainWithoutExplorer, | |
| hashOrAddress: address, | |
| }), | |
| ).toThrow() | |
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of scope for the test PR — adding a throw for missing explorers requires modifying the getExplorerLink implementation, which belongs in a separate fix PR. The existing tests already guard against the known explorer URL being used incorrectly.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This module is imported by non-React unit tests (e.g.
getExplorerLink.test.ts) just to accesscreateMockChain, but it eagerly imports Chakra + Testing Library and creates a Chakra system at module load. That adds unnecessary dependencies and startup work to pure utility tests, and can make tests more fragile if Chakra/test env setup changes. Suggest splitting into a non-Reactsrc/test-utils.ts(mocks likecreateMockChain) and a React-specificsrc/test-utils.tsx(render helpers), or at least moving the React/Chakra imports behindrenderWithProvidersso importingcreateMockChainstays lightweight.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The project uses ESM (
"type": "module"in package.json) sorequire()is not available — the suggested fix would break at runtime. Splitting the file into a.ts/.tsxpair would be the correct approach, but the startup overhead of Chakra in a Vitest jsdom environment is negligible (each test file gets its own isolated worker). Leaving as-is.