Skip to content

Commit c136e3c

Browse files
authored
feat(services): replace hardcoded prices with CoinGecko API (#487)
Adds priceService.ts which fetches real token USD prices from the CoinGecko free-tier simple/price endpoint. Implements a 5-minute in-memory cache (with optional AsyncStorage persistence) to reduce API calls and provide stale-data fallback when the API is unavailable. Adds useTokenPrices hook with 60-second periodic refresh, pull-to- refresh support, and graceful error handling for network failures, timeouts, and rate limiting. Removes all hardcoded mock price values from components and screens. Closes #70
1 parent dbd33e4 commit c136e3c

6 files changed

Lines changed: 1226 additions & 26 deletions

File tree

src/hooks/useTokenPrices.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { act, renderHook } from '@testing-library/react-native';
2+
import { clearPriceCache, fetchTokenPrices, getTokenPrice } from '../services/priceService';
3+
import { useTokenPrices } from './useTokenPrices';
4+
5+
const mockFetchTokenPrices = fetchTokenPrices as jest.MockedFunction<typeof fetchTokenPrices>;
6+
const mockGetTokenPrice = getTokenPrice as jest.MockedFunction<typeof getTokenPrice>;
7+
const mockClearPriceCache = clearPriceCache as jest.MockedFunction<typeof clearPriceCache>;
8+
9+
jest.mock('../services/priceService', () => ({
10+
TICKER_TO_COINGECKO_ID: {
11+
BTC: 'bitcoin',
12+
ETH: 'ethereum',
13+
XLM: 'stellar',
14+
SOL: 'solana',
15+
USDC: 'usd-coin',
16+
BNB: 'binancecoin',
17+
MATIC: 'polygon-ecosystem-token',
18+
ARB: 'arbitrum',
19+
DAI: 'dai',
20+
WBTC: 'wrapped-bitcoin',
21+
},
22+
fetchTokenPrices: jest.fn(),
23+
getTokenPrice: jest.fn(),
24+
clearPriceCache: jest.fn(),
25+
}));
26+
27+
function createPrice(id: string, usd: number, usd24hChange: number, available = true) {
28+
return {
29+
id,
30+
usd,
31+
usd24hChange,
32+
fetchedAt: 1_000,
33+
available,
34+
};
35+
}
36+
37+
async function flushPromises(): Promise<void> {
38+
await Promise.resolve();
39+
await Promise.resolve();
40+
}
41+
42+
beforeEach(() => {
43+
jest.useFakeTimers();
44+
jest.clearAllMocks();
45+
mockClearPriceCache.mockClear();
46+
mockGetTokenPrice.mockReturnValue(null);
47+
});
48+
49+
afterEach(() => {
50+
jest.runOnlyPendingTimers();
51+
jest.useRealTimers();
52+
});
53+
54+
describe('useTokenPrices', () => {
55+
it('returns isLoading true on initial mount before fetch resolves', async () => {
56+
let resolveFetch!: (value: Awaited<ReturnType<typeof fetchTokenPrices>>) => void;
57+
mockFetchTokenPrices.mockImplementationOnce(
58+
() =>
59+
new Promise<Awaited<ReturnType<typeof fetchTokenPrices>>>((resolve) => {
60+
resolveFetch = resolve;
61+
})
62+
);
63+
64+
const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] }));
65+
66+
expect(result.current.isLoading).toBe(true);
67+
expect(result.current.prices).toEqual({});
68+
69+
await act(async () => {
70+
resolveFetch({
71+
prices: {
72+
bitcoin: createPrice('bitcoin', 67000, 2.35),
73+
},
74+
fromCache: false,
75+
error: null,
76+
});
77+
await flushPromises();
78+
});
79+
80+
expect(result.current.isLoading).toBe(false);
81+
expect(result.current.prices.bitcoin.usd).toBe(67000);
82+
expect(result.current.error).toBeNull();
83+
});
84+
85+
it('returns prices and isLoading false after fetch resolves', async () => {
86+
mockFetchTokenPrices.mockResolvedValueOnce({
87+
prices: {
88+
bitcoin: createPrice('bitcoin', 67000, 2.35),
89+
},
90+
fromCache: false,
91+
error: null,
92+
});
93+
94+
const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] }));
95+
96+
await act(async () => {
97+
await flushPromises();
98+
});
99+
100+
expect(result.current.isLoading).toBe(false);
101+
expect(result.current.prices.bitcoin).toMatchObject({
102+
id: 'bitcoin',
103+
usd: 67000,
104+
usd24hChange: 2.35,
105+
available: true,
106+
});
107+
});
108+
109+
it('sets up an interval and re-fetches after refreshIntervalMs', async () => {
110+
let resolveRefresh!: (value: Awaited<ReturnType<typeof fetchTokenPrices>>) => void;
111+
mockFetchTokenPrices
112+
.mockResolvedValueOnce({
113+
prices: {
114+
bitcoin: createPrice('bitcoin', 67000, 2.35),
115+
},
116+
fromCache: false,
117+
error: null,
118+
})
119+
.mockImplementationOnce(
120+
() =>
121+
new Promise<Awaited<ReturnType<typeof fetchTokenPrices>>>((resolve) => {
122+
resolveRefresh = resolve;
123+
})
124+
);
125+
126+
const { result } = renderHook(() =>
127+
useTokenPrices({ tokenIds: ['BTC'], refreshIntervalMs: 60_000 })
128+
);
129+
130+
await act(async () => {
131+
await flushPromises();
132+
});
133+
134+
expect(mockFetchTokenPrices).toHaveBeenCalledTimes(1);
135+
expect(result.current.prices.bitcoin.usd).toBe(67000);
136+
137+
act(() => {
138+
jest.advanceTimersByTime(60_000);
139+
});
140+
141+
expect(mockFetchTokenPrices).toHaveBeenCalledTimes(2);
142+
143+
await act(async () => {
144+
resolveRefresh({
145+
prices: {
146+
bitcoin: createPrice('bitcoin', 68000, 1.1),
147+
},
148+
fromCache: false,
149+
error: null,
150+
});
151+
await flushPromises();
152+
});
153+
154+
expect(result.current.prices.bitcoin.usd).toBe(68000);
155+
});
156+
157+
it('cleans up interval on unmount', async () => {
158+
mockFetchTokenPrices.mockResolvedValueOnce({
159+
prices: {
160+
bitcoin: createPrice('bitcoin', 67000, 2.35),
161+
},
162+
fromCache: false,
163+
error: null,
164+
});
165+
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
166+
167+
const { unmount } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] }));
168+
169+
await act(async () => {
170+
await flushPromises();
171+
});
172+
173+
unmount();
174+
175+
expect(clearIntervalSpy).toHaveBeenCalled();
176+
clearIntervalSpy.mockRestore();
177+
});
178+
179+
it('sets isRefreshing true during manual refresh and false after', async () => {
180+
let resolveRefresh!: (value: Awaited<ReturnType<typeof fetchTokenPrices>>) => void;
181+
mockFetchTokenPrices
182+
.mockResolvedValueOnce({
183+
prices: {
184+
bitcoin: createPrice('bitcoin', 67000, 2.35),
185+
},
186+
fromCache: false,
187+
error: null,
188+
})
189+
.mockImplementationOnce(
190+
() =>
191+
new Promise<Awaited<ReturnType<typeof fetchTokenPrices>>>((resolve) => {
192+
resolveRefresh = resolve;
193+
})
194+
);
195+
196+
const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] }));
197+
198+
await act(async () => {
199+
await flushPromises();
200+
});
201+
202+
let refreshPromise!: Promise<void>;
203+
act(() => {
204+
refreshPromise = result.current.refresh();
205+
});
206+
207+
expect(result.current.isRefreshing).toBe(true);
208+
209+
await act(async () => {
210+
resolveRefresh({
211+
prices: {
212+
bitcoin: createPrice('bitcoin', 68000, 1.1),
213+
},
214+
fromCache: false,
215+
error: null,
216+
});
217+
await refreshPromise;
218+
await flushPromises();
219+
});
220+
221+
expect(result.current.isRefreshing).toBe(false);
222+
expect(result.current.prices.bitcoin.usd).toBe(68000);
223+
});
224+
225+
it('returns an error when fetch fails', async () => {
226+
mockFetchTokenPrices.mockResolvedValueOnce({
227+
prices: {
228+
bitcoin: createPrice('bitcoin', 0, 0, false),
229+
},
230+
fromCache: true,
231+
error: 'Unable to load prices',
232+
});
233+
234+
const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'] }));
235+
236+
await act(async () => {
237+
await flushPromises();
238+
});
239+
240+
expect(result.current.error).toBe('Unable to load prices');
241+
expect(result.current.prices.bitcoin.usd).toBe(0);
242+
expect(result.current.prices.bitcoin.available).toBe(false);
243+
});
244+
245+
it('does not fetch when enabled is false', () => {
246+
const { result } = renderHook(() => useTokenPrices({ tokenIds: ['BTC'], enabled: false }));
247+
248+
expect(mockFetchTokenPrices).not.toHaveBeenCalled();
249+
expect(result.current.isLoading).toBe(false);
250+
expect(result.current.prices).toEqual({});
251+
});
252+
});

0 commit comments

Comments
 (0)