diff --git a/apps/web/__tests__/usePoolTicks.test.ts b/apps/web/__tests__/usePoolTicks.test.ts new file mode 100644 index 0000000..5c1f142 --- /dev/null +++ b/apps/web/__tests__/usePoolTicks.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { usePools } from '@/hooks/usePoolTicks'; + +const mockPools = [ + { + id: 'pool-xlm-usdc-030', + token0: 'XLM', + token1: 'USDC', + token0Symbol: 'XLM', + token1Symbol: 'USDC', + feeTier: '0.30%', + currentPrice: 0.1085, + currentTick: -22000, + tvl: 4_200_000, + feeApr: 12.4, + volume24h: 340_000, + }, +]; + +describe('usePools', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows initial loading state then returns pool data', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: mockPools }), + }); + + const { result } = renderHook(() => usePools()); + + expect(result.current.loading).toBe(true); + expect(result.current.pools).toHaveLength(0); + expect(result.current.isStale).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pools).toEqual(mockPools); + expect(result.current.error).toBeNull(); + expect(result.current.isStale).toBe(false); + }); + + it('handles fetch failure gracefully with mock data and error', async () => { + global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => usePools()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.pools).toHaveLength(3); + expect(result.current.isStale).toBe(true); + }); + + it('handles HTTP error gracefully with mock data and error', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const { result } = renderHook(() => usePools()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('500'); + expect(result.current.pools).toHaveLength(3); + }); + + it('handles invalid JSON response gracefully', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => { throw new Error('Invalid JSON'); }, + }); + + const { result } = renderHook(() => usePools()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.pools).toHaveLength(3); + }); + + it('handles null response body gracefully', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => null, + }); + + const { result } = renderHook(() => usePools()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.pools).toHaveLength(3); + expect(result.current.isStale).toBe(true); + }); + + it('handles empty items array response', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: [] }), + }); + + const { result } = renderHook(() => usePools()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.pools).toHaveLength(0); + expect(result.current.error).toBeNull(); + expect(result.current.isStale).toBe(false); + }); + + it('cancels state updates on unmount', async () => { + let resolveFetch: () => void; + global.fetch = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = () => + resolve({ ok: true, json: async () => ({ items: mockPools }) }); + }) + ); + + const { result, unmount } = renderHook(() => usePools()); + + expect(result.current.loading).toBe(true); + + unmount(); + + resolveFetch!(); + + await waitFor(() => { + expect(result.current.pools).toHaveLength(0); + }); + }); +}); \ No newline at end of file diff --git a/apps/web/components/AddLiquidity/PoolSelector.tsx b/apps/web/components/AddLiquidity/PoolSelector.tsx index c7f31f1..fa27240 100644 --- a/apps/web/components/AddLiquidity/PoolSelector.tsx +++ b/apps/web/components/AddLiquidity/PoolSelector.tsx @@ -10,11 +10,18 @@ export interface PoolSelectorProps { } export function PoolSelector({ selected, onSelect }: PoolSelectorProps) { - const { pools, loading } = usePools(); + const { pools, loading, error, isStale } = usePools(); return (
-

Select pool

+
+

Select pool

+ {isStale && !loading && ( + + (cached) + + )} +
{loading ? (
{[1, 2, 3].map((i) => ( @@ -24,6 +31,8 @@ export function PoolSelector({ selected, onSelect }: PoolSelectorProps) { /> ))}
+ ) : error ? ( +

Failed to load pools. Showing cached data.

) : (
{pools.map((pool) => { diff --git a/apps/web/hooks/usePoolTicks.ts b/apps/web/hooks/usePoolTicks.ts index 414f443..7ea571d 100644 --- a/apps/web/hooks/usePoolTicks.ts +++ b/apps/web/hooks/usePoolTicks.ts @@ -67,19 +67,35 @@ export function usePoolTicks(poolId: string | null) { export function usePools() { const [pools, setPools] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); + setError(null); - fetch(`${API_BASE}/pools?limit=50&orderBy=tvl`) - .then((r) => r.json()) - .then((data: { items?: PoolDetail[] }) => { - if (!cancelled) setPools(data.items ?? []); + fetch(`${API_BASE}/pools?limit=50&orderBy=tvl`, { cache: 'no-store' }) + .then((r) => { + if (!r.ok) throw new Error(`Failed to load pools: HTTP ${r.status}`); + return r.json(); }) - .catch(() => { - if (!cancelled) setPools(MOCK_POOLS); + .then((data: unknown) => { + if (!cancelled) { + if (!data || typeof data !== 'object') { + throw new Error('Invalid response format'); + } + setPools(((data as { items?: PoolDetail[] }).items ?? [])); + setLastUpdated(Date.now()); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + const error = err instanceof Error ? err : new Error('Failed to load pools'); + setError(error); + setPools(MOCK_POOLS); + } }) .finally(() => { if (!cancelled) setLoading(false); @@ -90,7 +106,9 @@ export function usePools() { }; }, []); - return { pools, loading }; + const isStale = lastUpdated === null; + + return { pools, loading, error, isStale }; } function generateSyntheticTicks(): TickData[] { diff --git a/apps/web/hooks/usePools.ts b/apps/web/hooks/usePools.ts index 13af23b..5ca3be6 100644 --- a/apps/web/hooks/usePools.ts +++ b/apps/web/hooks/usePools.ts @@ -32,7 +32,7 @@ interface UsePoolsParams { } export function usePools({ page, orderBy, search }: UsePoolsParams) { - return useQuery({ + const query = useQuery({ queryKey: ['pools', page, orderBy, search], queryFn: async () => { const params = new URLSearchParams({ @@ -45,7 +45,11 @@ export function usePools({ page, orderBy, search }: UsePoolsParams) { if (!res.ok) throw new Error('Failed to fetch pools'); return res.json(); }, - refetchInterval: 30_000, placeholderData: (prev) => prev, + refetchInterval: 30_000, }); + + const isStale = query.data === undefined && !query.isLoading; + + return { ...query, isStale }; }