diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index aa40888875..5c80738fb9 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -8,6 +8,7 @@ import { } from 'react'; import { useValue } from '../../internal/hooks/useValue'; import { useEmitLifecycleStatus } from '../hooks/useEmitLifecycleStatus'; +import { useOnrampExchangeRate } from '../hooks/useOnrampExhangeRate'; import { usePaymentMethods } from '../hooks/usePaymentMethods'; import type { AmountInputType, @@ -17,7 +18,6 @@ import type { PaymentMethod, PresetAmountInputs, } from '../types'; -import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; type FundCardContextType = { asset: string; @@ -92,40 +92,24 @@ export function FundCardProvider({ onStatus, }); - const fetchExchangeRate = useCallback(async () => { - setExchangeRateLoading(true); - - try { - const quote = await fetchOnrampQuote({ - purchaseCurrency: asset, - paymentCurrency: currency, - paymentAmount: '100', - paymentMethod: 'CARD', - country, - subdivision, - }); + const { fetchExchangeRate } = useOnrampExchangeRate({ + asset, + currency, + country, + subdivision, + setExchangeRate, + onError, + }); - setExchangeRate( - Number(quote.purchaseAmount.value) / - Number(quote.paymentSubtotal.value), - ); - } catch (err) { - if (err instanceof Error) { - console.error('Error fetching exchange rate:', err); - onError?.({ - errorType: 'handled_error', - code: 'EXCHANGE_RATE_ERROR', - debugMessage: err.message, - }); - } - } finally { - setExchangeRateLoading(false); - } - }, [asset, country, subdivision, currency, onError]); + const handleFetchExchangeRate = useCallback(async () => { + setExchangeRateLoading(true); + await fetchExchangeRate(); + setExchangeRateLoading(false); + }, [fetchExchangeRate]); // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect useEffect(() => { - fetchExchangeRate(); + handleFetchExchangeRate(); }, []); // Fetches and sets the payment methods to the context diff --git a/src/fund/hooks/useOnrampExchangeRate.test.ts b/src/fund/hooks/useOnrampExchangeRate.test.ts new file mode 100644 index 0000000000..7aa163c936 --- /dev/null +++ b/src/fund/hooks/useOnrampExchangeRate.test.ts @@ -0,0 +1,84 @@ +import { renderHook } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { quoteResponseDataMock } from '../mocks'; +import type { OnrampError } from '../types'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { useOnrampExchangeRate } from './useOnrampExhangeRate'; + +vi.mock('../utils/fetchOnrampQuote'); + +describe('useOnrampExchangeRate', () => { + const mockSetExchangeRate = vi.fn(); + const mockOnError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (fetchOnrampQuote as Mock).mockResolvedValue(quoteResponseDataMock); + }); + + it('fetches and calculates exchange rate correctly', async () => { + const { result } = renderHook(() => + useOnrampExchangeRate({ + asset: 'ETH', + currency: 'USD', + country: 'US', + setExchangeRate: mockSetExchangeRate, + }), + ); + + await result.current.fetchExchangeRate(); + + // Verify exchange rate calculation + expect(mockSetExchangeRate).toHaveBeenCalledWith( + Number(quoteResponseDataMock.purchaseAmount.value) / + Number(quoteResponseDataMock.paymentSubtotal.value), + ); + }); + + it('handles API errors', async () => { + const error = new Error('API Error'); + (fetchOnrampQuote as Mock).mockRejectedValue(error); + + const { result } = renderHook(() => + useOnrampExchangeRate({ + asset: 'ETH', + currency: 'USD', + country: 'US', + setExchangeRate: mockSetExchangeRate, + onError: mockOnError, + }), + ); + + await result.current.fetchExchangeRate(); + + // Should call onError with correct error object + expect(mockOnError).toHaveBeenCalledWith({ + errorType: 'handled_error', + code: 'EXCHANGE_RATE_ERROR', + debugMessage: 'API Error', + } satisfies OnrampError); + }); + + it('includes subdivision in API call when provided', async () => { + const { result } = renderHook(() => + useOnrampExchangeRate({ + asset: 'ETH', + currency: 'USD', + country: 'US', + subdivision: 'CA', + setExchangeRate: mockSetExchangeRate, + }), + ); + + await result.current.fetchExchangeRate(); + + expect(fetchOnrampQuote).toHaveBeenCalledWith({ + purchaseCurrency: 'ETH', + paymentCurrency: 'USD', + paymentAmount: '100', + paymentMethod: 'CARD', + country: 'US', + subdivision: 'CA', + }); + }); +}); diff --git a/src/fund/hooks/useOnrampExhangeRate.ts b/src/fund/hooks/useOnrampExhangeRate.ts new file mode 100644 index 0000000000..259ce15f01 --- /dev/null +++ b/src/fund/hooks/useOnrampExhangeRate.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo } from 'react'; +import type { OnrampError } from '../types'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; + +export const useOnrampExchangeRate = ({ + asset, + currency, + country, + subdivision, + setExchangeRate, + onError, +}: { + asset: string; + currency: string; + country: string; + subdivision?: string; + setExchangeRate: (exchangeRate: number) => void; + onError?: (error: OnrampError) => void; +}) => { + const fetchExchangeRate = useCallback(async () => { + try { + const quote = await fetchOnrampQuote({ + purchaseCurrency: asset, + paymentCurrency: currency, + paymentAmount: '100', + paymentMethod: 'CARD', + country, + subdivision, + }); + + setExchangeRate( + Number(quote.purchaseAmount.value) / + Number(quote.paymentSubtotal.value), + ); + } catch (err) { + if (err instanceof Error) { + console.error('Error fetching exchange rate:', err); + onError?.({ + errorType: 'handled_error', + code: 'EXCHANGE_RATE_ERROR', + debugMessage: err.message, + }); + } + } + }, [asset, country, subdivision, currency, onError, setExchangeRate]); + + return useMemo(() => ({ fetchExchangeRate }), [fetchExchangeRate]); +};