From 7453a4c51aaf659666921c45f04ca615efa06604 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:09:05 +0000 Subject: [PATCH] feat: Add comprehensive Bridge integration tests - Add BridgeCrossChainFlow.integration.test.tsx for cross-chain transaction flows (EVM-to-EVM, EVM-to-Solana, Solana-to-EVM) - Add BridgeQuoteValidation.integration.test.tsx for quote validation and external provider integration - Add BridgeTimeoutHandling.integration.test.tsx for timeout handling and quote expiration scenarios - Add BridgeProviderErrors.integration.test.tsx for external bridge provider error scenarios - Enhance testUtils/index.ts with additional mock utilities for integration testing - Cover comprehensive user workflows from token selection through quote fetching to transaction submission - Include error handling for rate limiting, service unavailability, network issues, and provider maintenance - Test quote expiration, refresh cycles, and timeout recovery mechanisms Co-Authored-By: mason.batchelor@windsurf.com --- app/components/UI/Bridge/testUtils/index.ts | 61 ++ .../BridgeCrossChainFlow.integration.test.tsx | 398 ++++++++++++ .../BridgeProviderErrors.integration.test.tsx | 573 ++++++++++++++++++ ...BridgeQuoteValidation.integration.test.tsx | 425 +++++++++++++ ...BridgeTimeoutHandling.integration.test.tsx | 418 +++++++++++++ .../UI/Bridge/tests-integration/index.test.ts | 4 + 6 files changed, 1879 insertions(+) create mode 100644 app/components/UI/Bridge/tests-integration/BridgeCrossChainFlow.integration.test.tsx create mode 100644 app/components/UI/Bridge/tests-integration/BridgeProviderErrors.integration.test.tsx create mode 100644 app/components/UI/Bridge/tests-integration/BridgeQuoteValidation.integration.test.tsx create mode 100644 app/components/UI/Bridge/tests-integration/BridgeTimeoutHandling.integration.test.tsx create mode 100644 app/components/UI/Bridge/tests-integration/index.test.ts diff --git a/app/components/UI/Bridge/testUtils/index.ts b/app/components/UI/Bridge/testUtils/index.ts index 2e8538ef6cd..7cd526b5923 100644 --- a/app/components/UI/Bridge/testUtils/index.ts +++ b/app/components/UI/Bridge/testUtils/index.ts @@ -1,10 +1,13 @@ import { type BridgeControllerState, getDefaultBridgeControllerState, + type QuoteResponse, + RequestStatus, } from '@metamask/bridge-controller'; import { initialState } from '../_mocks_/initialState'; import { mockBridgeReducerState } from '../_mocks_/bridgeReducerState'; import type { BridgeState } from '../../../../core/redux/slices/bridge'; +import { Hex } from '@metamask/utils'; type BridgeControllerStateOverride = Partial; @@ -50,3 +53,61 @@ export const createBridgeTestState = ( }, }; }; + +/** + * Creates mock quote responses for testing external provider scenarios + */ +export const createMockQuoteResponse = (overrides: Partial = {}): QuoteResponse => ({ + quote: { + destTokenAmount: '1000000', + bridgePriceData: { priceImpact: -0.002 }, + ...overrides.quote, + }, + estimatedProcessingTimeInSeconds: 60, + totalNetworkFee: { + amount: '0.01', + valueInCurrency: '10', + }, + ...overrides, +} as QuoteResponse); + +/** + * Creates mock bridge controller state for error scenarios + */ +export const createMockErrorState = ( + errorMessage: string, + status: RequestStatus = RequestStatus.LOADING, +): Partial => ({ + quotes: [], + quotesLoadingStatus: status, + quoteFetchError: errorMessage, + quotesLastFetched: null, +}); + +/** + * Creates mock bridge controller state for timeout scenarios + */ +export const createMockTimeoutState = ( + lastFetched: number | null = null, + refreshCount: number = 0, +): Partial => ({ + quotesLastFetched: lastFetched, + quotesRefreshCount: refreshCount, + quotesLoadingStatus: RequestStatus.FETCHED, +}); + +/** + * Creates mock token for testing + */ +export const createMockToken = ( + symbol: string, + chainId: Hex, + address: string, + decimals: number = 18, +) => ({ + address, + chainId, + decimals, + symbol, + name: symbol, +}); diff --git a/app/components/UI/Bridge/tests-integration/BridgeCrossChainFlow.integration.test.tsx b/app/components/UI/Bridge/tests-integration/BridgeCrossChainFlow.integration.test.tsx new file mode 100644 index 00000000000..cd6d43d9aaa --- /dev/null +++ b/app/components/UI/Bridge/tests-integration/BridgeCrossChainFlow.integration.test.tsx @@ -0,0 +1,398 @@ +import { renderScreen } from '../../../../util/test/renderWithProvider'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import Routes from '../../../../constants/navigation/Routes'; +import BridgeView from '../Views/BridgeView'; +import { createBridgeTestState } from '../testUtils'; +import { RequestStatus, type QuoteResponse } from '@metamask/bridge-controller'; +import mockQuotes from '../_mocks_/mock-quotes-sol-sol.json'; +import Engine from '../../../../core/Engine'; +import { SolScope } from '@metamask/keyring-api'; +import { Hex } from '@metamask/utils'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + BridgeController: { + updateBridgeQuoteRequestParams: jest.fn(), + resetState: jest.fn(), + setBridgeFeatureFlags: jest.fn().mockResolvedValue(undefined), + }, + BridgeStatusController: { + submitTx: jest.fn().mockResolvedValue({ success: true }), + }, + SwapsController: { + fetchAggregatorMetadataWithCache: jest.fn(), + fetchTopAssetsWithCache: jest.fn(), + fetchTokenWithCache: jest.fn(), + }, + KeyringController: { + state: { + keyrings: [ + { + accounts: ['0x1234567890123456789012345678901234567890'], + type: 'HD Key Tree', + }, + ], + }, + }, + GasFeeController: { + startPolling: jest.fn(), + stopPollingByPollingToken: jest.fn(), + }, + NetworkController: { + getNetworkConfigurationByNetworkClientId: jest.fn(), + }, + }, + getTotalEvmFiatAccountBalance: jest.fn().mockReturnValue({ + balance: '1000000000000000000', + fiatBalance: '2000', + }), +})); + +jest.mock('../../../../hooks/useAccounts', () => ({ + useAccounts: () => ({ + accounts: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Account 1', + type: 'HD Key Tree', + yOffset: 0, + isSelected: true, + }, + ], + }), +})); + +jest.mock('../hooks/useLatestBalance', () => ({ + useLatestBalance: jest.fn().mockImplementation(({ address, chainId }) => { + if (!address || !chainId) return undefined; + const actualEthers = jest.requireActual('ethers'); + return { + displayBalance: '2.0', + atomicBalance: actualEthers.BigNumber.from('2000000000000000000'), + }; + }), +})); + +jest.mock('../../../../../component-library/components/Skeleton', () => ({ + Skeleton: () => null, +})); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: jest.fn(), + }), + }; +}); + +const mockSuccessfulQuoteResponse = { + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + quotesLastFetched: Date.now(), + quoteFetchError: null, +}; + +const mockCrossChainProviderError = { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Bridge provider unavailable', + quotesLastFetched: null, +}; + +describe('Bridge Cross-Chain Transaction Flow Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigate.mockClear(); + }); + + describe('EVM to EVM Cross-Chain Flow', () => { + it('should complete full quote fetching flow for Ethereum to Polygon bridge', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockSuccessfulQuoteResponse, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x89' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + selectedDestChainId: '0x89' as Hex, + sourceAmount: '1.0', + }, + }); + + const { getByText, getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(getByText('ETH')).toBeTruthy(); + expect(getByTestId('source-token-area-input').props.value).toBe('1.0'); + expect(getByText('Continue')).toBeTruthy(); + + const continueButton = getByText('Continue'); + fireEvent.press(continueButton); + + await waitFor(() => { + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + srcChainId: 1, + destChainId: 137, + srcTokenAmount: '1000000000000000000', + }), + undefined, + ); + }); + }); + + it('should handle cross-chain quote fetching errors gracefully', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockCrossChainProviderError, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0xa4b1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + selectedDestChainId: '0xa4b1' as Hex, + sourceAmount: '0.5', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); + + describe('EVM to Solana Cross-Chain Flow', () => { + it('should handle EVM to Solana bridge transaction flow', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockSuccessfulQuoteResponse, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: SolScope.Mainnet, + decimals: 9, + symbol: 'SOL', + name: 'Solana', + }, + selectedDestChainId: SolScope.Mainnet, + sourceAmount: '0.1', + destAddress: 'FakeS0LanaAddr3ss111111111111111111111111111', + }, + }); + + const { getByText, getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(getByText('ETH')).toBeTruthy(); + expect(getByTestId('source-token-area-input').props.value).toBe('0.1'); + + await waitFor(() => { + expect(getByText('Continue')).toBeTruthy(); + }); + }); + + it('should validate destination address for Solana transactions', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockSuccessfulQuoteResponse, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: SolScope.Mainnet, + decimals: 9, + symbol: 'SOL', + name: 'Solana', + }, + selectedDestChainId: SolScope.Mainnet, + sourceAmount: '0.1', + destAddress: undefined, + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); + + describe('Solana to EVM Cross-Chain Flow', () => { + it('should handle Solana to EVM bridge transaction flow', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockSuccessfulQuoteResponse, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: SolScope.Mainnet, + decimals: 9, + symbol: 'SOL', + name: 'Solana', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + selectedDestChainId: '0x1' as Hex, + sourceAmount: '1.0', + }, + }); + + const { getByText, getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(getByText('SOL')).toBeTruthy(); + expect(getByTestId('source-token-area-input').props.value).toBe('1.0'); + + await waitFor(() => { + expect(getByText('Continue')).toBeTruthy(); + }); + }); + }); + + describe('Token Switching Flow', () => { + it('should handle token switching in cross-chain scenarios', async () => { + const initialStateWithTokens = createBridgeTestState({ + bridgeControllerOverrides: mockSuccessfulQuoteResponse, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + selectedDestChainId: '0x89' as Hex, + sourceAmount: '1.0', + }, + }); + + const { getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: initialStateWithTokens }, + ); + + const arrowButton = getByTestId('arrow-button'); + fireEvent.press(arrowButton); + + await waitFor(() => { + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalled(); + }); + }); + }); + + describe('Quote Refresh Flow', () => { + it('should handle automatic quote refresh during cross-chain flow', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + quotesLastFetched: Date.now() - 35000, + quotesRefreshCount: 1, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x89' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + selectedDestChainId: '0x89' as Hex, + sourceAmount: '1.0', + }, + }); + + renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + await waitFor(() => { + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/app/components/UI/Bridge/tests-integration/BridgeProviderErrors.integration.test.tsx b/app/components/UI/Bridge/tests-integration/BridgeProviderErrors.integration.test.tsx new file mode 100644 index 00000000000..be897b0c004 --- /dev/null +++ b/app/components/UI/Bridge/tests-integration/BridgeProviderErrors.integration.test.tsx @@ -0,0 +1,573 @@ +import { renderScreen } from '../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../testUtils'; +import BridgeView from '../Views/BridgeView'; +import { RequestStatus } from '@metamask/bridge-controller'; +import Engine from '../../../../core/Engine'; +import { Hex } from '@metamask/utils'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + BridgeController: { + updateBridgeQuoteRequestParams: jest.fn(), + resetState: jest.fn(), + setBridgeFeatureFlags: jest.fn().mockResolvedValue(undefined), + }, + BridgeStatusController: { + submitTx: jest.fn().mockResolvedValue({ success: true }), + }, + SwapsController: { + fetchAggregatorMetadataWithCache: jest.fn(), + fetchTopAssetsWithCache: jest.fn(), + fetchTokenWithCache: jest.fn(), + }, + KeyringController: { + state: { + keyrings: [ + { + accounts: ['0x1234567890123456789012345678901234567890'], + type: 'HD Key Tree', + }, + ], + }, + }, + GasFeeController: { + startPolling: jest.fn(), + stopPollingByPollingToken: jest.fn(), + }, + NetworkController: { + getNetworkConfigurationByNetworkClientId: jest.fn(), + }, + }, + getTotalEvmFiatAccountBalance: jest.fn().mockReturnValue({ + balance: '1000000000000000000', + fiatBalance: '2000', + }), +})); + +jest.mock('../../../../hooks/useAccounts', () => ({ + useAccounts: () => ({ + accounts: [ + { + address: '0x1234567890123456789012345678901234567890', + name: 'Account 1', + type: 'HD Key Tree', + yOffset: 0, + isSelected: true, + }, + ], + }), +})); + +jest.mock('../hooks/useLatestBalance', () => ({ + useLatestBalance: jest.fn().mockImplementation(({ address, chainId }) => { + if (!address || !chainId) return undefined; + const actualEthers = jest.requireActual('ethers'); + return { + displayBalance: '2.0', + atomicBalance: actualEthers.BigNumber.from('2000000000000000000'), + }; + }), +})); + +jest.mock('../../../../../component-library/components/Skeleton', () => ({ + Skeleton: () => null, +})); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: jest.fn(), + }), + }; +}); + +const mockProviderErrors = { + rateLimited: { + quoteFetchError: 'Rate limit exceeded. Please try again later.', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, + serviceUnavailable: { + quoteFetchError: 'Bridge service temporarily unavailable', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, + invalidTokenPair: { + quoteFetchError: 'Token pair not supported by bridge providers', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, + networkError: { + quoteFetchError: 'Network error: Unable to connect to bridge API', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, + providerMaintenance: { + quoteFetchError: 'Bridge providers are under maintenance', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, + insufficientLiquidity: { + quoteFetchError: 'Insufficient liquidity for this token pair', + quotesLoadingStatus: RequestStatus.LOADING, + quotes: [], + quotesLastFetched: null, + }, +}; + +describe('Bridge External Provider Error Scenarios', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigate.mockClear(); + }); + + describe('Provider Error Handling', () => { + it('should display appropriate error message for rate limiting', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.rateLimited, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle network connectivity issues gracefully', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.networkError, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle service unavailable errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.serviceUnavailable, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0xa4b1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + sourceAmount: '0.5', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle unsupported token pair errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.invalidTokenPair, + bridgeReducerOverrides: { + sourceToken: { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'UNKNOWN', + name: 'Unknown Token', + }, + destToken: { + address: '0x0987654321098765432109876543210987654321', + chainId: '0x89' as Hex, + decimals: 18, + symbol: 'UNKNOWN2', + name: 'Unknown Token 2', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); + + describe('Provider Maintenance Scenarios', () => { + it('should handle provider maintenance gracefully', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.providerMaintenance, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle insufficient liquidity errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderErrors.insufficientLiquidity, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1000.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); + + describe('Error Recovery Scenarios', () => { + it('should allow retry after provider error', async () => { + Engine.context.BridgeController.updateBridgeQuoteRequestParams = jest + .fn() + .mockRejectedValueOnce(new Error('Provider error')) + .mockResolvedValueOnce(undefined); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Provider error', + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + const input = getByTestId('source-token-area-input'); + fireEvent.changeText(input, '1.5'); + + await waitFor(() => { + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalled(); + }); + }); + + it('should handle intermittent provider failures', async () => { + let callCount = 0; + Engine.context.BridgeController.updateBridgeQuoteRequestParams = jest + .fn() + .mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return Promise.reject(new Error('Intermittent failure')); + } + return Promise.resolve(); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { getByTestId } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + const input = getByTestId('source-token-area-input'); + + fireEvent.changeText(input, '1.1'); + fireEvent.changeText(input, '1.2'); + fireEvent.changeText(input, '1.3'); + + await waitFor(() => { + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalledTimes(3); + }); + }); + }); + + describe('Complex Error Scenarios', () => { + it('should handle mixed provider responses with some errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Some providers failed: LiFi (timeout), Socket (rate limited)', + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle cascading provider failures', async () => { + Engine.context.BridgeController.updateBridgeQuoteRequestParams = jest + .fn() + .mockRejectedValue(new Error('All providers failed')); + + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'All bridge providers are currently unavailable', + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0xa4b1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); + + describe('Provider-Specific Error Handling', () => { + it('should handle LiFi provider specific errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'LiFi: Route not found for this token pair', + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'RARE', + name: 'Rare Token', + }, + destToken: { + address: '0x0987654321098765432109876543210987654321', + chainId: '0x89' as Hex, + decimals: 18, + symbol: 'RARE2', + name: 'Rare Token 2', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + + it('should handle Socket provider specific errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Socket: Bridge not supported for this route', + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x38' as Hex, + decimals: 18, + symbol: 'BNB', + name: 'Binance Coin', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x2105' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + sourceAmount: '1.0', + }, + }); + + const { queryByText } = renderScreen( + BridgeView, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + expect(queryByText('Continue')).toBeFalsy(); + }); + }); +}); diff --git a/app/components/UI/Bridge/tests-integration/BridgeQuoteValidation.integration.test.tsx b/app/components/UI/Bridge/tests-integration/BridgeQuoteValidation.integration.test.tsx new file mode 100644 index 00000000000..e2e83094ed4 --- /dev/null +++ b/app/components/UI/Bridge/tests-integration/BridgeQuoteValidation.integration.test.tsx @@ -0,0 +1,425 @@ +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../testUtils'; +import { useBridgeQuoteRequest } from '../hooks/useBridgeQuoteRequest'; +import { useBridgeQuoteData } from '../hooks/useBridgeQuoteData'; +import Engine from '../../../../core/Engine'; +import { RequestStatus, type QuoteResponse } from '@metamask/bridge-controller'; +import { act } from '@testing-library/react-native'; +import { Hex } from '@metamask/utils'; +import mockQuotes from '../_mocks_/mock-quotes-sol-sol.json'; + +jest.mock('../../../../../core/Engine', () => ({ + context: { + BridgeController: { + updateBridgeQuoteRequestParams: jest.fn(), + resetState: jest.fn(), + }, + }, +})); + +jest.mock('../hooks/useUnifiedSwapBridgeContext', () => ({ + useUnifiedSwapBridgeContext: jest.fn(), +})); + +const mockProviderResponses = { + multipleProviders: { + quotes: [ + { + quote: { destTokenAmount: '1000000', bridgePriceData: { priceImpact: -0.002 } }, + estimatedProcessingTimeInSeconds: 60, + totalNetworkFee: { amount: '0.01', valueInCurrency: '10' }, + }, + { + quote: { destTokenAmount: '1050000', bridgePriceData: { priceImpact: -0.001 } }, + estimatedProcessingTimeInSeconds: 90, + totalNetworkFee: { amount: '0.015', valueInCurrency: '15' }, + }, + ] as unknown as QuoteResponse[], + quotesLoadingStatus: RequestStatus.FETCHED, + quotesLastFetched: Date.now(), + }, + providerTimeout: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Request timeout after 30 seconds', + quotesLastFetched: null, + }, + invalidQuoteData: { + quotes: [ + { + quote: null, + estimatedProcessingTimeInSeconds: 0, + }, + ] as unknown as QuoteResponse[], + quotesLoadingStatus: RequestStatus.FETCHED, + quotesLastFetched: Date.now(), + }, + rateLimitError: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Rate limit exceeded. Please try again later.', + quotesLastFetched: null, + }, +}; + +describe('Bridge Quote Validation Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('External Provider Integration', () => { + it('should handle multiple bridge provider responses and select best quote', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderResponses.multipleProviders, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.bestQuote).toBeDefined(); + expect(result.current.activeQuote).toBeDefined(); + expect(result.current.isLoading).toBe(false); + expect(result.current.quoteFetchError).toBeNull(); + }); + + it('should validate quote parameters before sending to external providers', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + selectedDestChainId: '0x1' as Hex, + sourceAmount: '1.5', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(700); + }); + + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + srcChainId: 1, + destChainId: 1, + srcTokenAmount: '1500000000000000000', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + undefined, + ); + }); + + it('should handle missing required parameters gracefully', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: undefined, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(700); + }); + + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).not.toHaveBeenCalled(); + }); + + it('should handle provider timeout errors', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderResponses.providerTimeout, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.quoteFetchError).toBe('Request timeout after 30 seconds'); + expect(result.current.isLoading).toBe(false); + expect(result.current.bestQuote).toBeNull(); + }); + + it('should handle rate limiting from external providers', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderResponses.rateLimitError, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.quoteFetchError).toBe('Rate limit exceeded. Please try again later.'); + expect(result.current.isLoading).toBe(false); + expect(result.current.bestQuote).toBeNull(); + }); + }); + + describe('Quote Data Processing', () => { + it('should process and format quote data correctly', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + quotesLastFetched: Date.now(), + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: 'So11111111111111111111111111111111111111112', + chainId: '0xfa' as Hex, + decimals: 9, + symbol: 'SOL', + name: 'Solana', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.formattedQuoteData).toBeDefined(); + expect(result.current.formattedQuoteData?.estimatedTime).toMatch(/\d+ min/); + expect(result.current.formattedQuoteData?.rate).toMatch(/1 ETH = .* SOL/); + expect(result.current.destTokenAmount).toBeDefined(); + }); + + it('should handle invalid quote data gracefully', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockProviderResponses.invalidQuoteData, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.bestQuote).toBeDefined(); + expect(result.current.activeQuote).toBeDefined(); + }); + }); + + describe('Quote Parameter Validation', () => { + it('should validate decimal precision for token amounts', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + destToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + selectedDestChainId: '0x1' as Hex, + sourceAmount: '1000.123456', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(700); + }); + + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '1000123456', + }), + undefined, + ); + }); + + it('should handle edge case amounts like decimal point only', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + selectedDestChainId: '0x1' as Hex, + sourceAmount: '.', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteRequest(), { + state: testState, + }); + + await act(async () => { + await result.current(); + jest.advanceTimersByTime(700); + }); + + expect(Engine.context.BridgeController.updateBridgeQuoteRequestParams).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '0', + }), + undefined, + ); + }); + }); + + describe('Loading States', () => { + it('should handle loading state during quote fetching', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [], + quotesLoadingStatus: RequestStatus.LOADING, + quotesLastFetched: null, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.bestQuote).toBeUndefined(); + expect(result.current.activeQuote).toBeUndefined(); + }); + }); +}); diff --git a/app/components/UI/Bridge/tests-integration/BridgeTimeoutHandling.integration.test.tsx b/app/components/UI/Bridge/tests-integration/BridgeTimeoutHandling.integration.test.tsx new file mode 100644 index 00000000000..88733cd5084 --- /dev/null +++ b/app/components/UI/Bridge/tests-integration/BridgeTimeoutHandling.integration.test.tsx @@ -0,0 +1,418 @@ +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../testUtils'; +import { useBridgeQuoteData } from '../hooks/useBridgeQuoteData'; +import { isQuoteExpired, shouldRefreshQuote, getQuoteRefreshRate } from '../utils/quoteUtils'; +import { RequestStatus, type QuoteResponse } from '@metamask/bridge-controller'; +import { act } from '@testing-library/react-native'; +import { Hex } from '@metamask/utils'; +import mockQuotes from '../_mocks_/mock-quotes-sol-sol.json'; + +const mockSelectPrimaryCurrency = jest.fn(); +jest.mock('../../../../selectors/settings', () => ({ + ...jest.requireActual('../../../../selectors/settings'), + selectPrimaryCurrency: () => mockSelectPrimaryCurrency(), +})); + +jest.mock('../../../../selectors/networkController', () => ({ + selectTicker: () => 'ETH', +})); + +jest.mock('../../SimulationDetails/FiatDisplay/useFiatFormatter', () => ({ + __esModule: true, + default: () => (value: any) => `$${value.toString()}`, +})); + +const mockTimeScenarios = { + expiredQuote: { + quotesLastFetched: Date.now() - 60000, + quotesRefreshCount: 2, + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + }, + maxRefreshReached: { + quotesLastFetched: Date.now() - 30000, + quotesRefreshCount: 5, + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + }, + networkTimeout: { + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Network timeout', + quotesLastFetched: null, + quotesRefreshCount: 0, + }, + recentQuote: { + quotesLastFetched: Date.now() - 10000, + quotesRefreshCount: 1, + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + }, +}; + +describe('Bridge Timeout and Error Handling Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + (isQuoteExpired as jest.Mock).mockReturnValue(false); + (getQuoteRefreshRate as jest.Mock).mockReturnValue(30000); + (shouldRefreshQuote as jest.Mock).mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('Quote Expiration Scenarios', () => { + it('should handle quote expiration with automatic refresh', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockTimeScenarios.expiredQuote, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.isExpired).toBe(true); + expect(result.current.willRefresh).toBe(true); + expect(result.current.activeQuote).toBeUndefined(); + }); + + it('should stop refreshing after max attempts and show expired modal', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockTimeScenarios.maxRefreshReached, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.willRefresh).toBe(false); + expect(result.current.isExpired).toBe(true); + expect(result.current.activeQuote).toBeUndefined(); + }); + + it('should not expire recent quotes', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockTimeScenarios.recentQuote, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.isExpired).toBe(false); + expect(result.current.willRefresh).toBe(true); + expect(result.current.activeQuote).toBeDefined(); + }); + }); + + describe('Network Timeout Scenarios', () => { + it('should handle network timeout during quote fetching', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: mockTimeScenarios.networkTimeout, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.quoteFetchError).toBe('Network timeout'); + expect(result.current.bestQuote).toBeUndefined(); + }); + + it('should handle recovery from network timeout', async () => { + let testState = createBridgeTestState({ + bridgeControllerOverrides: mockTimeScenarios.networkTimeout, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result, rerender } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.quoteFetchError).toBe('Network timeout'); + + testState = createBridgeTestState({ + bridgeControllerOverrides: { + ...mockTimeScenarios.recentQuote, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + rerender({ state: testState }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.quoteFetchError).toBeNull(); + expect(result.current.bestQuote).toBeDefined(); + }); + }); + + describe('Refresh Rate Logic', () => { + it('should use mocked refresh rate', () => { + (getQuoteRefreshRate as jest.Mock).mockReturnValue(20000); + + const sourceToken = { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }; + + const refreshRate = getQuoteRefreshRate(undefined, sourceToken); + expect(refreshRate).toBe(20000); + }); + + it('should fall back to default refresh rate when no feature flags', () => { + (getQuoteRefreshRate as jest.Mock).mockReturnValue(30000); + + const sourceToken = { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }; + + const refreshRate = getQuoteRefreshRate(undefined, sourceToken); + expect(refreshRate).toBe(30000); + }); + }); + + describe('Refresh Logic Validation', () => { + it('should not refresh when user has insufficient balance', () => { + const shouldRefresh = shouldRefreshQuote(true, 2, 5, false); + expect(shouldRefresh).toBe(false); + }); + + it('should not refresh when transaction is being submitted', () => { + const shouldRefresh = shouldRefreshQuote(false, 2, 5, true); + expect(shouldRefresh).toBe(false); + }); + + it('should refresh when under max attempts and conditions are met', () => { + const shouldRefresh = shouldRefreshQuote(false, 2, 5, false); + expect(shouldRefresh).toBe(true); + }); + + it('should not refresh when max attempts reached', () => { + const shouldRefresh = shouldRefreshQuote(false, 5, 5, false); + expect(shouldRefresh).toBe(false); + }); + }); + + describe('Quote Expiration Logic', () => { + it('should detect expired quotes correctly', () => { + const oldTimestamp = Date.now() - 35000; + const refreshRate = 30000; + + const expired = isQuoteExpired(false, refreshRate, oldTimestamp); + expect(expired).toBe(true); + }); + + it('should not mark quotes as expired when they will refresh', () => { + const oldTimestamp = Date.now() - 35000; + const refreshRate = 30000; + + const expired = isQuoteExpired(true, refreshRate, oldTimestamp); + expect(expired).toBe(false); + }); + + it('should handle null timestamp gracefully', () => { + const refreshRate = 30000; + + const expired = isQuoteExpired(false, refreshRate, null); + expect(expired).toBe(false); + }); + }); + + describe('Complex Timeout Scenarios', () => { + it('should handle multiple consecutive timeouts', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotesLoadingStatus: RequestStatus.LOADING, + quoteFetchError: 'Multiple consecutive timeouts', + quotesLastFetched: null, + quotesRefreshCount: 3, + quotes: [], + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.quoteFetchError).toBe('Multiple consecutive timeouts'); + expect(result.current.isLoading).toBe(false); + expect(result.current.isNoQuotesAvailable).toBe(false); + }); + + it('should handle partial provider responses with timeouts', async () => { + const testState = createBridgeTestState({ + bridgeControllerOverrides: { + quotes: [mockQuotes[0] as unknown as QuoteResponse], + quotesLoadingStatus: RequestStatus.FETCHED, + quoteFetchError: 'Some providers timed out', + quotesLastFetched: Date.now(), + quotesRefreshCount: 1, + quoteRequest: { insufficientBal: false }, + }, + bridgeReducerOverrides: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }, + destToken: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + }, + sourceAmount: '1.0', + }, + }); + + const { result } = renderHookWithProvider(() => useBridgeQuoteData(), { + state: testState, + }); + + expect(result.current.bestQuote).toBeDefined(); + expect(result.current.activeQuote).toBeDefined(); + expect(result.current.quoteFetchError).toBe('Some providers timed out'); + expect(result.current.isLoading).toBe(false); + }); + }); +}); diff --git a/app/components/UI/Bridge/tests-integration/index.test.ts b/app/components/UI/Bridge/tests-integration/index.test.ts new file mode 100644 index 00000000000..59257319d93 --- /dev/null +++ b/app/components/UI/Bridge/tests-integration/index.test.ts @@ -0,0 +1,4 @@ +import './BridgeCrossChainFlow.integration.test'; +import './BridgeQuoteValidation.integration.test'; +import './BridgeTimeoutHandling.integration.test'; +import './BridgeProviderErrors.integration.test';