From 9107a0c1f2f35cd7f94f353c695dc528c1e5e45d Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Fri, 17 Oct 2025 13:25:00 -0300 Subject: [PATCH] feat(card): display card button with geolocation and feature flag guards (#21306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces new logic for displaying the Card button on the Wallet Home screen. Key Changes - Added geolocation fetching and verification to determine if the user is in a supported region. - Added new feature flags to control progressive rollout of the Card button. - Introduced a new experimental setting within the Settings → Experimental section to always display the Card button (for internal testing). Display Logic The Card button is shown under the following conditions (in order of priority): 1. Experimental flag enabled → Always show the Card button. 2. Experimental flag disabled, but the user is a cardholder (on-chain data) → Show the Card button. 3. Experimental flag disabled, but the user is logged in with Baanx → Show the Card button. 4. Experimental flag disabled, user is in a supported region, and feature flag (progressive rollout) is enabled → Show the Card button. ## **Changelog** CHANGELOG entry: New Enable Card Button section on Experimental Options ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Display of the Card button is now controlled by geolocation and feature flags with a new experimental override, Redux-backed auth state, and updated SDK/geolocation handling. > > - **Card button display logic**: > - Add Redux state and selectors in `core/redux/slices/card` for `geoLocation`, `isAuthenticated`, `alwaysShowCardButton`, and `selectDisplayCardButton` (combines cardholder, auth, country support, and FFs). > - Replace wallet navbar guard with `selectDisplayCardButton` in `Views/Wallet`. > - Add experimental toggle in `Views/Settings/ExperimentalSettings` to persist `alwaysShowCardButton` (strings added in `locales`). > - **Feature flags/selectors**: > - New selectors in `selectors/featureFlagController/card`: `selectCardSupportedCountries`, `selectDisplayCardButtonFeatureFlag`, `selectCardExperimentalSwitch`; `selectCardFeatureFlag` now returns a default config when absent. > - **SDK/auth integration**: > - `CardSDK.getGeoLocation` now uses env-based endpoints and returns `UNKNOWN` on failure. > - `getCardholder` returns `{ cardholderAddresses, geoLocation }` and callers/store updated accordingly. > - `Card SDK` context and `useCardProviderAuthentication` now dispatch `setIsAuthenticatedCard` to Redux; `CardHome` reads auth via `selectIsAuthenticatedCard`. > - **Tests**: > - Comprehensive updates across card SDK, selectors, hooks, reducers, wallet, and views to cover new logic and states (geolocation, flags, auth). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a4425719bb5282fbbd5d37b0b2165eeac86560e1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 349 +++++----- .../UI/Card/Views/CardHome/CardHome.tsx | 8 +- .../UI/Card/hooks/isBaanxLoginEnabled.test.ts | 199 ++---- .../useCardProviderAuthentication.test.ts | 28 +- .../hooks/useCardProviderAuthentication.ts | 9 +- .../UI/Card/hooks/useCardholderCheck.test.ts | 30 +- .../UI/Card/hooks/useCardholderCheck.ts | 7 +- app/components/UI/Card/sdk/CardSDK.test.ts | 86 ++- app/components/UI/Card/sdk/CardSDK.ts | 23 +- app/components/UI/Card/sdk/index.test.tsx | 598 ++++++------------ app/components/UI/Card/sdk/index.tsx | 66 +- .../UI/Card/util/getCardholder.test.ts | 159 ++++- app/components/UI/Card/util/getCardholder.ts | 21 +- .../ExperimentalSettings/index.test.tsx | 19 + .../Settings/ExperimentalSettings/index.tsx | 37 +- app/components/Views/Wallet/index.test.tsx | 1 + app/components/Views/Wallet/index.tsx | 8 +- app/core/redux/slices/card/index.test.ts | 429 ++++++++++++- app/core/redux/slices/card/index.ts | 80 ++- .../featureFlagController/card/index.test.ts | 362 ++++++++++- .../featureFlagController/card/index.ts | 179 +++++- locales/languages/en.json | 4 +- 22 files changed, 1814 insertions(+), 888 deletions(-) diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index fc28c63ddd36..b06488bff896 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -25,8 +25,6 @@ const mockSdk = { jest.mock('../../sdk', () => ({ useCardSDK: jest.fn(() => ({ sdk: mockSdk, - isAuthenticated: false, - setIsAuthenticated: mockSetIsAuthenticated, isLoading: false, logoutFromProvider: mockLogoutFromProvider, userCardLocation: 'international' as const, @@ -60,7 +58,10 @@ import { selectDepositMinimumVersionFlag, } from '../../../../../selectors/featureFlagController/deposit'; import { selectChainId } from '../../../../../selectors/networkController'; -import { selectCardholderAccounts } from '../../../../../core/redux/slices/card'; +import { + selectCardholderAccounts, + selectIsAuthenticatedCard, +} from '../../../../../core/redux/slices/card'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -313,6 +314,57 @@ jest.mock('react', () => { }; }); +// Helper: Setup mock selectors with default values +function setupMockSelectors( + overrides?: Partial<{ + privacyMode: boolean; + depositActive: boolean; + depositMinVersion: string; + chainId: string; + cardholderAccounts: string[]; + selectedAccount: typeof mockSelectedInternalAccount; + isAuthenticated: boolean; + }>, +) { + const defaults = { + privacyMode: false, + depositActive: true, + depositMinVersion: '0.9.0', + chainId: '0xe708', + cardholderAccounts: [mockCurrentAddress], + selectedAccount: mockSelectedInternalAccount, + isAuthenticated: false, + }; + + const config = { ...defaults, ...overrides }; + + mockUseSelector.mockImplementation((selector) => { + if (!selector) return []; + + if (selector === selectPrivacyMode) return config.privacyMode; + if (selector === selectDepositActiveFlag) return config.depositActive; + if (selector === selectDepositMinimumVersionFlag) + return config.depositMinVersion; + if (selector === selectChainId) return config.chainId; + if (selector === selectCardholderAccounts) return config.cardholderAccounts; + if (selector === selectIsAuthenticatedCard) return config.isAuthenticated; + + const selectorString = + typeof selector === 'function' ? selector.toString() : ''; + if (selectorString.includes('selectSelectedInternalAccount')) + return config.selectedAccount; + if (selectorString.includes('selectChainId')) return config.chainId; + if (selectorString.includes('selectCardholderAccounts')) + return config.cardholderAccounts; + if (selectorString.includes('selectEvmTokens')) return [mockPriorityToken]; + if (selectorString.includes('selectEvmTokenFiatBalances')) + return ['1000.00']; + + return []; + }); +} + +// Helper: Render component with proper wrapper function render() { return renderScreen( withCardSDK(CardHome), @@ -337,10 +389,11 @@ describe('CardHome Component', () => { mockLogoutFromProvider.mockClear(); mockSetIsAuthenticated.mockClear(); + // Setup Engine controller mocks mockFetchPriorityToken.mockImplementation(async () => mockPriorityToken); mockDispatch.mockClear(); mockSetActiveNetwork.mockResolvedValue(undefined); - mockFindNetworkClientIdByChainId.mockReturnValue(''); // Prevent network switching in most tests - empty string is falsy + mockFindNetworkClientIdByChainId.mockReturnValue(''); // Prevent network switching mockSetPrivacyMode.mockClear(); mockGetAccountByAddress.mockReturnValue({ id: 'account-id', @@ -357,7 +410,7 @@ describe('CardHome Component', () => { }); mockSetSelectedAccount.mockClear(); - // Setup the mock for useGetPriorityCardToken + // Setup hook mocks with default values (useGetPriorityCardToken as jest.Mock).mockReturnValue({ priorityToken: mockPriorityToken, fetchPriorityToken: mockFetchPriorityToken, @@ -398,41 +451,16 @@ describe('CardHome Component', () => { mockCreateEventBuilder.mockReturnValue(mockEventBuilder); - mockUseSelector.mockImplementation((selector) => { - // Guard against unexpected undefined/null selector - if (!selector) { - return []; - } - - // Direct identity checks first (more robust than string matching) - if (selector === selectPrivacyMode) return false; - if (selector === selectDepositActiveFlag) return true; - if (selector === selectDepositMinimumVersionFlag) return '0.9.0'; - if (selector === selectChainId) return '0xe708'; // Linea chain ID - if (selector === selectCardholderAccounts) return [mockCurrentAddress]; - - // Fallback to string inspection (Jest wraps anonymous selector fns sometimes) - const selectorString = - typeof selector === 'function' ? selector.toString() : ''; - if (selectorString.includes('selectSelectedInternalAccount')) - return mockSelectedInternalAccount; - if (selectorString.includes('selectChainId')) return '0xe708'; - if (selectorString.includes('selectCardholderAccounts')) - return [mockCurrentAddress]; - if (selectorString.includes('selectEvmTokens')) - return [mockPriorityToken]; - if (selectorString.includes('selectEvmTokenFiatBalances')) - return ['1000.00']; - - // Default safe fallback - return []; - }); + // Setup default selectors + setupMockSelectors(); }); it('renders correctly and matches snapshot', async () => { + // Given: default state with priority token + // When: component renders const { toJSON } = render(); - // Wait for any async operations to complete + // Then: should match snapshot await waitFor(() => { expect(toJSON()).toBeDefined(); }); @@ -441,33 +469,13 @@ describe('CardHome Component', () => { }); it('renders correctly with privacy mode enabled', async () => { - // Temporarily override privacy mode for this test - mockUseSelector.mockImplementation((selector) => { - if (!selector) return []; - - if (selector === selectPrivacyMode) return true; // Enable privacy mode for this test - if (selector === selectDepositActiveFlag) return true; - if (selector === selectDepositMinimumVersionFlag) return '0.9.0'; - if (selector === selectChainId) return '0xe708'; - if (selector === selectCardholderAccounts) return [mockCurrentAddress]; - - const selectorString = - typeof selector === 'function' ? selector.toString() : ''; - if (selectorString.includes('selectSelectedInternalAccount')) - return mockSelectedInternalAccount; - if (selectorString.includes('selectChainId')) return '0xe708'; - if (selectorString.includes('selectCardholderAccounts')) - return [mockCurrentAddress]; - if (selectorString.includes('selectEvmTokens')) - return [mockPriorityToken]; - if (selectorString.includes('selectEvmTokenFiatBalances')) - return ['$1,000.00']; - return []; - }); + // Given: privacy mode is enabled + setupMockSelectors({ privacyMode: true }); + // When: component renders const { toJSON } = render(); - // Check that privacy is enabled + // Then: should show privacy indicators and match snapshot expect( screen.getByTestId(CardHomeSelectors.PRIVACY_TOGGLE_BUTTON), ).toBeTruthy(); @@ -477,6 +485,8 @@ describe('CardHome Component', () => { }); it('opens AddFundsBottomSheet when add funds button is pressed with USDC token', async () => { + // Given: priority token is USDC (default) + // When: user presses add funds button render(); const addFundsButton = screen.getByTestId( @@ -484,17 +494,16 @@ describe('CardHome Component', () => { ); fireEvent.press(addFundsButton); - // Check that the AddFundsBottomSheet actually appears + // Then: should open bottom sheet, not swaps await waitFor(() => { expect(screen.getByTestId('add-funds-bottom-sheet')).toBeTruthy(); }); - // Since the default token is USDC, it should open the bottom sheet instead of calling openSwaps expect(mockOpenSwaps).not.toHaveBeenCalled(); }); it('opens AddFundsBottomSheet when add funds button is pressed with USDT token', async () => { - // Use USDT token which should also open the bottom sheet + // Given: priority token is USDT const usdtToken = { ...mockPriorityToken, symbol: 'USDT', @@ -507,6 +516,7 @@ describe('CardHome Component', () => { error: null, }); + // When: user presses add funds button render(); const addFundsButton = screen.getByTestId( @@ -514,17 +524,16 @@ describe('CardHome Component', () => { ); fireEvent.press(addFundsButton); - // Check that the AddFundsBottomSheet actually appears + // Then: should open bottom sheet for supported token await waitFor(() => { expect(screen.getByTestId('add-funds-bottom-sheet')).toBeTruthy(); }); - // USDT should also open the bottom sheet, not call openSwaps expect(mockOpenSwaps).not.toHaveBeenCalled(); }); - it('calls goToSwaps when add funds button is pressed with non-USDC token', async () => { - // Use a non-USDC token + it('calls goToSwaps when add funds button is pressed with non-supported token', async () => { + // Given: priority token is ETH (not supported for deposit) const ethToken = { ...mockPriorityToken, symbol: 'ETH', @@ -538,17 +547,16 @@ describe('CardHome Component', () => { }); render(); + mockOpenSwaps.mockClear(); + mockTrackEvent.mockClear(); + // When: user presses add funds button const addFundsButton = screen.getByTestId( CardHomeSelectors.ADD_FUNDS_BUTTON, ); - - // Reset mocks to ensure clean state - mockOpenSwaps.mockClear(); - mockTrackEvent.mockClear(); - fireEvent.press(addFundsButton); + // Then: should navigate to swaps await waitFor(() => { expect(mockTrackEvent).toHaveBeenCalled(); expect(mockOpenSwaps).toHaveBeenCalledWith({ @@ -558,6 +566,8 @@ describe('CardHome Component', () => { }); it('calls navigateToCardPage when advanced card management is pressed', async () => { + // Given: default state + // When: user presses advanced management item render(); const advancedManagementItem = screen.getByTestId( @@ -565,44 +575,37 @@ describe('CardHome Component', () => { ); fireEvent.press(advancedManagementItem); + // Then: should navigate to card page await waitFor(() => { expect(mockNavigateToCardPage).toHaveBeenCalled(); }); }); it('displays correct priority token information', async () => { + // Given: USDC is the priority token + // When: component renders with privacy mode off render(); - // Check that we can see the USDC token info (this should work regardless of balance visibility) + // Then: should show token and balance information expect(screen.getByText('USDC')).toBeTruthy(); - - // Since privacy mode is off by default, we should see the balance expect(screen.getByTestId('balance-test-id')).toBeTruthy(); expect(screen.getByTestId('secondary-balance-test-id')).toBeTruthy(); }); it('displays manage card section', () => { + // Given: default state + // When: component renders render(); + // Then: should show manage card section expect( screen.getByTestId(CardHomeSelectors.ADVANCED_CARD_MANAGEMENT_ITEM), ).toBeTruthy(); }); - it('displays priority token information when available', async () => { - render(); - - await waitFor(() => { - // Check that USDC token is displayed - expect(screen.getByText('USDC')).toBeTruthy(); - - // Since privacy mode is off by default, we should see the balance elements - expect(screen.getByTestId('balance-test-id')).toBeTruthy(); - expect(screen.getByTestId('secondary-balance-test-id')).toBeTruthy(); - }); - }); - it('toggles privacy mode when privacy toggle button is pressed', async () => { + // Given: privacy mode is off + // When: user presses privacy toggle button render(); const privacyToggleButton = screen.getByTestId( @@ -610,21 +613,16 @@ describe('CardHome Component', () => { ); fireEvent.press(privacyToggleButton); + // Then: should toggle privacy mode await waitFor(() => { - // Since privacy mode starts as false, toggling should set it to true - // But based on the error, it seems to be called with false - // Let's check what was actually called expect(mockSetPrivacyMode).toHaveBeenCalled(); - - // The component logic is: toggleIsBalanceAndAssetsHidden(!privacyMode) - // If privacyMode is false, !privacyMode is true, so it should be called with true - // But if there's an issue with the mock, let's just verify it was called const calls = mockSetPrivacyMode.mock.calls; expect(calls.length).toBeGreaterThan(0); }); }); it('displays error state when there is an error fetching priority token', () => { + // Given: priority token fetch failed (useGetPriorityCardToken as jest.Mock).mockReturnValueOnce({ priorityToken: null, fetchPriorityToken: mockFetchPriorityToken, @@ -632,14 +630,17 @@ describe('CardHome Component', () => { error: 'Failed to fetch token', }); + // When: component renders render(); + // Then: should show error state expect(screen.getByText('Unable to load card')).toBeTruthy(); expect(screen.getByText('Please try again later')).toBeTruthy(); expect(screen.getByTestId(CardHomeSelectors.TRY_AGAIN_BUTTON)).toBeTruthy(); }); it('calls fetchPriorityToken when try again button is pressed', async () => { + // Given: error state is displayed (useGetPriorityCardToken as jest.Mock).mockReturnValueOnce({ priorityToken: null, fetchPriorityToken: mockFetchPriorityToken, @@ -649,17 +650,20 @@ describe('CardHome Component', () => { render(); + // When: user presses try again button const tryAgainButton = screen.getByTestId( CardHomeSelectors.TRY_AGAIN_BUTTON, ); fireEvent.press(tryAgainButton); + // Then: should retry fetching priority token await waitFor(() => { expect(mockFetchPriorityToken).toHaveBeenCalled(); }); }); it('displays limited allowance warning when allowance state is limited', () => { + // Given: priority token has limited allowance const limitedAllowanceToken = { ...mockPriorityToken, allowanceState: AllowanceState.Limited, @@ -672,12 +676,15 @@ describe('CardHome Component', () => { error: null, }); + // When: component renders render(); + // Then: should display limited allowance warning expect(screen.getByText('Limited spending allowance')).toBeTruthy(); }); it('sets navigation options correctly', () => { + // Given: navigation object const mockNavigation = { navigate: mockNavigate, goBack: mockGoBack, @@ -685,50 +692,21 @@ describe('CardHome Component', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; + // When: getting navigation options const navigationOptions = cardDefaultNavigationOptions({ navigation: mockNavigation, }); + // Then: should include all required header components expect(navigationOptions).toHaveProperty('headerLeft'); expect(navigationOptions).toHaveProperty('headerTitle'); expect(navigationOptions).toHaveProperty('headerRight'); }); - it('navigates to wallet home when close button is pressed', () => { - const mockNavigation = { - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetNavigationOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - const navigationOptions = cardDefaultNavigationOptions({ - navigation: mockNavigation, - }); - - expect(navigationOptions.headerLeft).toBeDefined(); - }); - - it('displays card title in header', () => { - const mockNavigation = { - navigate: mockNavigate, - goBack: mockGoBack, - setOptions: mockSetNavigationOptions, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - const navigationOptions = cardDefaultNavigationOptions({ - navigation: mockNavigation, - }); - - expect(navigationOptions.headerTitle).toBeDefined(); - }); - - it('dispatches bridge tokens when opening swaps with non-USDC token', async () => { - // Reset useFocusEffect to default mock for this test + it('dispatches bridge tokens when opening swaps with non-supported token', async () => { + // Given: ETH token (not supported for deposit) jest.mocked(useFocusEffect).mockImplementation(jest.fn()); - // Use a non-USDC token to trigger the swaps flow const ethToken = { ...mockPriorityToken, symbol: 'ETH', @@ -742,17 +720,16 @@ describe('CardHome Component', () => { }); render(); + mockOpenSwaps.mockClear(); + mockTrackEvent.mockClear(); + // When: user presses add funds button const addFundsButton = screen.getByTestId( CardHomeSelectors.ADD_FUNDS_BUTTON, ); - - // Reset mocks to ensure clean state - mockOpenSwaps.mockClear(); - mockTrackEvent.mockClear(); - fireEvent.press(addFundsButton); + // Then: should navigate to swaps with correct chain await waitFor(() => { expect(mockTrackEvent).toHaveBeenCalled(); expect(mockOpenSwaps).toHaveBeenCalledWith({ @@ -762,6 +739,7 @@ describe('CardHome Component', () => { }); it('falls back to mainBalance when balanceFiat is TOKEN_RATE_UNDEFINED', () => { + // Given: fiat rate is undefined mockUseAssetBalance.mockReturnValue({ balanceFiat: TOKEN_RATE_UNDEFINED, asset: { @@ -774,16 +752,17 @@ describe('CardHome Component', () => { rawFiatNumber: 0, }); + // When: component renders render(); - // Should display the mainBalance when rate is undefined - // The main balance should be displayed in the balance-test-id element + // Then: should display main balance instead of fiat expect(screen.getByTestId('balance-test-id')).toHaveTextContent( '1000 USDC', ); }); - it('displays fallback balance when balanceFiat is not available', () => { + it('falls back to mainBalance when balanceFiat is not available', () => { + // Given: fiat balance is empty mockUseAssetBalance.mockReturnValue({ balanceFiat: '', asset: { @@ -796,27 +775,33 @@ describe('CardHome Component', () => { rawFiatNumber: 0, }); + // When: component renders render(); - // Should display the mainBalance when balanceFiat is not available - // The main balance should be displayed in the balance-test-id element + // Then: should display main balance as fallback expect(screen.getByTestId('balance-test-id')).toHaveTextContent( '1000 USDC', ); }); - it('fires CARD_HOME_VIEWED once after both balances valid (fiat + main)', async () => { - // Arrange: fiat and main are valid and token exists by default from beforeEach + it('fires CARD_HOME_VIEWED once when balances are loaded', async () => { + // Given: both fiat and main balances are valid + // When: component renders render(); + // Then: should fire metric once await waitFor(() => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); }); - it('includes raw numeric properties in CARD_HOME_VIEWED event when both balances valid', async () => { + it('includes raw numeric properties in CARD_HOME_VIEWED event', async () => { + // Given: balances with numeric values + // When: component renders and metrics fire render(); await new Promise((r) => setTimeout(r, 0)); + + // Then: should include raw balance properties expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ token_raw_balance_priority: 1000, @@ -825,7 +810,8 @@ describe('CardHome Component', () => { ); }); - it('fires metric with raw balance 0 for zero balances', async () => { + it('includes zero raw balances in metrics', async () => { + // Given: zero balances mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$0.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -835,8 +821,11 @@ describe('CardHome Component', () => { rawFiatNumber: 0, }); + // When: component renders render(); await new Promise((r) => setTimeout(r, 0)); + + // Then: should include zero values in metrics expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ token_raw_balance_priority: 0, @@ -845,7 +834,8 @@ describe('CardHome Component', () => { ); }); - it('fires metric when only main balance is valid (fiat undefined) and includes rawTokenBalance only', async () => { + it('includes only rawTokenBalance when fiat is undefined', async () => { + // Given: only main balance is valid (fiat undefined) mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: undefined as unknown as string, asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -854,9 +844,12 @@ describe('CardHome Component', () => { rawTokenBalance: 1000, // rawFiatNumber intentionally omitted (undefined) }); + + // When: component renders render(); await new Promise((r) => setTimeout(r, 0)); - // event fired + + // Then: should include only token balance in metrics expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ @@ -866,7 +859,8 @@ describe('CardHome Component', () => { ); }); - it('fires metric when only fiat balance is valid (main undefined) and includes rawFiatNumber only', async () => { + it('includes only rawFiatNumber when main balance is undefined', async () => { + // Given: only fiat balance is valid (main undefined) mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -875,8 +869,12 @@ describe('CardHome Component', () => { // rawTokenBalance omitted rawFiatNumber: 1000, }); + + // When: component renders render(); await new Promise((r) => setTimeout(r, 0)); + + // Then: should include only fiat balance in metrics expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ @@ -886,7 +884,8 @@ describe('CardHome Component', () => { ); }); - it('fires CARD_HOME_VIEWED once when only mainBalance is valid (fiat undefined)', async () => { + it('fires CARD_HOME_VIEWED once when only mainBalance is valid', async () => { + // Given: only main balance is available mockUseAssetBalance.mockReturnValue({ balanceFiat: undefined as unknown as string, asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -896,18 +895,20 @@ describe('CardHome Component', () => { // rawFiatNumber omitted }); + // When: component renders render(); + // Then: should fire metric once and not re-fire await waitFor(() => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); - // No additional calls after stabilization await new Promise((r) => setTimeout(r, 0)); expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); - it('fires CARD_HOME_VIEWED once when only fiat balance is valid (main undefined)', async () => { + it('fires CARD_HOME_VIEWED once when only fiat balance is valid', async () => { + // Given: only fiat balance is available mockUseAssetBalance.mockReturnValue({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -917,18 +918,20 @@ describe('CardHome Component', () => { rawFiatNumber: 1000, }); + // When: component renders render(); + // Then: should fire metric once and not re-fire await waitFor(() => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); - // Ensure no re-fire await new Promise((r) => setTimeout(r, 0)); expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); - it('does not fire when only loading sentinels present', async () => { + it('does not fire metrics when balances are still loading', async () => { + // Given: balances show loading sentinels mockUseAssetBalance.mockReturnValue({ balanceFiat: 'tokenBalanceLoading', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -937,14 +940,16 @@ describe('CardHome Component', () => { // raw values omitted }); + // When: component renders render(); - // Give time for any effects + // Then: should not fire metrics while loading await new Promise((r) => setTimeout(r, 0)); expect(mockTrackEvent).not.toHaveBeenCalled(); }); - it('does not fire when fiat is TOKEN_RATE_UNDEFINED and main is undefined', async () => { + it('does not fire metrics when balances are unavailable', async () => { + // Given: fiat is undefined and main is also undefined mockUseAssetBalance.mockReturnValue({ balanceFiat: 'tokenRateUndefined', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -953,79 +958,91 @@ describe('CardHome Component', () => { // raw values omitted }); + // When: component renders render(); + // Then: should not fire metrics without valid balance await new Promise((r) => setTimeout(r, 0)); expect(mockTrackEvent).not.toHaveBeenCalled(); }); - it('converts NaN rawTokenBalance to 0 in tracking event', async () => { + it('converts NaN rawTokenBalance to 0 in metrics', async () => { + // Given: rawTokenBalance is NaN mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, mainBalance: '1000 USDC', secondaryBalance: '1000 USDC', - rawTokenBalance: NaN, // This should be converted to 0 + rawTokenBalance: NaN, rawFiatNumber: 1000, }); + // When: component renders and fires metrics render(); await new Promise((r) => setTimeout(r, 0)); + // Then: should convert NaN to 0 in metrics expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ - token_raw_balance_priority: 0, // NaN should become 0 + token_raw_balance_priority: 0, token_fiat_balance_priority: 1000, }), ); }); - it('converts NaN rawFiatNumber to 0 in tracking event', async () => { + it('converts NaN rawFiatNumber to 0 in metrics', async () => { + // Given: rawFiatNumber is NaN mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, mainBalance: '1000 USDC', secondaryBalance: '1000 USDC', rawTokenBalance: 1000, - rawFiatNumber: NaN, // This should be converted to 0 + rawFiatNumber: NaN, }); + // When: component renders and fires metrics render(); await new Promise((r) => setTimeout(r, 0)); + // Then: should convert NaN to 0 in metrics expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ token_raw_balance_priority: 1000, - token_fiat_balance_priority: 0, // NaN should become 0 + token_fiat_balance_priority: 0, }), ); }); - it('converts both NaN raw values to 0 in tracking event', async () => { + it('converts both NaN raw values to 0 in metrics', async () => { + // Given: both raw values are NaN mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, mainBalance: '1000 USDC', secondaryBalance: '1000 USDC', - rawTokenBalance: NaN, // This should be converted to 0 - rawFiatNumber: NaN, // This should be converted to 0 + rawTokenBalance: NaN, + rawFiatNumber: NaN, }); + // When: component renders and fires metrics render(); await new Promise((r) => setTimeout(r, 0)); + // Then: should convert both NaN values to 0 expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ - token_raw_balance_priority: 0, // NaN should become 0 - token_fiat_balance_priority: 0, // NaN should become 0 + token_raw_balance_priority: 0, + token_fiat_balance_priority: 0, }), ); }); - it('preserves undefined raw values (does not convert to 0) in tracking event', async () => { + it('preserves undefined raw values in metrics', async () => { + // Given: raw values are undefined (not provided) mockUseAssetBalance.mockReturnValueOnce({ balanceFiat: '$1,000.00', asset: { symbol: 'USDC', image: 'usdc-image-url' }, @@ -1034,14 +1051,16 @@ describe('CardHome Component', () => { // rawTokenBalance and rawFiatNumber intentionally omitted (undefined) }); + // When: component renders and fires metrics render(); await new Promise((r) => setTimeout(r, 0)); + // Then: should preserve undefined values (not convert to 0) expect(mockTrackEvent).toHaveBeenCalled(); expect(mockEventBuilder.addProperties).toHaveBeenCalledWith( expect.objectContaining({ - token_raw_balance_priority: undefined, // undefined should remain undefined - token_fiat_balance_priority: undefined, // undefined should remain undefined + token_raw_balance_priority: undefined, + token_fiat_balance_priority: undefined, }), ); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 1194cafc4830..843cc88bbed9 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -53,6 +53,7 @@ import { DEPOSIT_SUPPORTED_TOKENS } from '../../constants'; import { useCardSDK } from '../../sdk'; import Routes from '../../../../../constants/navigation/Routes'; import useIsBaanxLoginEnabled from '../../hooks/isBaanxLoginEnabled'; +import { selectIsAuthenticatedCard } from '../../../../../core/redux/slices/card'; /** * CardHome Component @@ -70,11 +71,8 @@ const CardHome = () => { const [openAddFundsBottomSheet, setOpenAddFundsBottomSheet] = useState(false); const [retries, setRetries] = useState(0); const sheetRef = useRef(null); - const { - isAuthenticated, - logoutFromProvider, - isLoading: isSDKLoading, - } = useCardSDK(); + const isAuthenticated = useSelector(selectIsAuthenticatedCard); + const { logoutFromProvider, isLoading: isSDKLoading } = useCardSDK(); const isBaanxLoginEnabled = useIsBaanxLoginEnabled(); const { trackEvent, createEventBuilder } = useMetrics(); diff --git a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts index da525072bb64..646891c38493 100644 --- a/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts +++ b/app/components/UI/Card/hooks/isBaanxLoginEnabled.test.ts @@ -9,187 +9,82 @@ jest.mock('../sdk', () => ({ const mockUseCardSDK = useCardSDK as jest.MockedFunction; +const createMockSDK = (isBaanxLoginEnabled: boolean): Partial => ({ + get isBaanxLoginEnabled() { + return isBaanxLoginEnabled; + }, + get isCardEnabled() { + return true; + }, + get supportedTokens() { + return []; + }, + isCardHolder: jest.fn(), + getGeoLocation: jest.fn(), + getSupportedTokensAllowances: jest.fn(), + getPriorityToken: jest.fn(), +}); + +const mockCardSDKResponse = (sdk: Partial | null) => { + mockUseCardSDK.mockReturnValue({ + sdk: sdk as CardSDK | null, + isLoading: false, + logoutFromProvider: jest.fn(), + userCardLocation: 'international', + }); +}; + describe('useIsBaanxLoginEnabled', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('should return false when SDK is null', () => { - mockUseCardSDK.mockReturnValue({ - sdk: null, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); - - const { result } = renderHook(() => useIsBaanxLoginEnabled()); - - expect(result.current).toBe(false); - expect(mockUseCardSDK).toHaveBeenCalledTimes(1); - }); - - it('should return false when SDK is undefined', () => { - mockUseCardSDK.mockReturnValue({ - sdk: null, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); + it('returns false when SDK is null', () => { + // Given: SDK is not available + mockCardSDKResponse(null); + // When: hook is rendered const { result } = renderHook(() => useIsBaanxLoginEnabled()); + // Then: should return false expect(result.current).toBe(false); - expect(mockUseCardSDK).toHaveBeenCalledTimes(1); }); - it('should return true when SDK exists and isBaanxLoginEnabled is true', () => { - const mockSdk = { - get isBaanxLoginEnabled() { - return true; - }, - get isCardEnabled() { - return true; - }, - get supportedTokens() { - return []; - }, - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - }; - - mockUseCardSDK.mockReturnValue({ - sdk: mockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); + it('returns true when Baanx login is enabled', () => { + // Given: SDK with Baanx login enabled + mockCardSDKResponse(createMockSDK(true)); + // When: hook is rendered const { result } = renderHook(() => useIsBaanxLoginEnabled()); + // Then: should return true expect(result.current).toBe(true); - expect(mockUseCardSDK).toHaveBeenCalledTimes(1); }); - it('should return false when SDK exists and isBaanxLoginEnabled is false', () => { - const mockSdk = { - get isBaanxLoginEnabled() { - return false; - }, - get isCardEnabled() { - return true; - }, - get supportedTokens() { - return []; - }, - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - }; - - mockUseCardSDK.mockReturnValue({ - sdk: mockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); + it('returns false when Baanx login is disabled', () => { + // Given: SDK with Baanx login disabled + mockCardSDKResponse(createMockSDK(false)); + // When: hook is rendered const { result } = renderHook(() => useIsBaanxLoginEnabled()); + // Then: should return false expect(result.current).toBe(false); - expect(mockUseCardSDK).toHaveBeenCalledTimes(1); }); - it('should call useCardSDK hook correctly', () => { - const mockSdk = { - get isBaanxLoginEnabled() { - return true; - }, - get isCardEnabled() { - return true; - }, - get supportedTokens() { - return []; - }, - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - }; - - mockUseCardSDK.mockReturnValue({ - sdk: mockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); - - renderHook(() => useIsBaanxLoginEnabled()); - - expect(mockUseCardSDK).toHaveBeenCalledWith(); - expect(mockUseCardSDK).toHaveBeenCalledTimes(1); - }); - - it('should re-render when SDK value changes', () => { - const mockSdk = { - get isBaanxLoginEnabled() { - return false; - }, - get isCardEnabled() { - return true; - }, - get supportedTokens() { - return []; - }, - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - }; - - mockUseCardSDK.mockReturnValue({ - sdk: mockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); - + it('updates when SDK value changes', () => { + // Given: SDK with Baanx login disabled + mockCardSDKResponse(createMockSDK(false)); const { result, rerender } = renderHook(() => useIsBaanxLoginEnabled()); + // Then: should return false initially expect(result.current).toBe(false); - const updatedMockSdk = { - ...mockSdk, - get isBaanxLoginEnabled() { - return true; - }, - }; - - mockUseCardSDK.mockReturnValue({ - sdk: updatedMockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), - isLoading: false, - logoutFromProvider: jest.fn(), - userCardLocation: 'international', - }); - + // When: SDK changes to enable Baanx login + mockCardSDKResponse(createMockSDK(true)); rerender(); + // Then: should return true expect(result.current).toBe(true); }); }); diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts index 218b13033b21..7e83eedec007 100644 --- a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts +++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts @@ -12,12 +12,20 @@ import { } from '../types'; import { CardSDK } from '../sdk/CardSDK'; import { strings } from '../../../../../locales/i18n'; +import { useDispatch } from 'react-redux'; +import { setIsAuthenticatedCard } from '../../../../core/redux/slices/card'; jest.mock('@sentry/core'); jest.mock('../sdk'); jest.mock('../util/cardTokenVault'); jest.mock('../util/pkceHelpers'); jest.mock('../../../../../locales/i18n'); +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); +jest.mock('../../../../core/redux/slices/card', () => ({ + setIsAuthenticatedCard: jest.fn(), +})); const mockUuid4 = uuid4 as jest.MockedFunction; const mockUseCardSDK = useCardSDK as jest.MockedFunction; @@ -31,6 +39,9 @@ const mockGenerateState = generateState as jest.MockedFunction< typeof generateState >; const mockStrings = strings as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetIsAuthenticatedCard = + setIsAuthenticatedCard as jest.MockedFunction; describe('useCardProviderAuthentication', () => { const mockSdk = { @@ -53,7 +64,7 @@ describe('useCardProviderAuthentication', () => { exchangeToken: jest.fn(), refreshLocalToken: jest.fn(), }; - const mockSetIsAuthenticated = jest.fn(); + const mockDispatch = jest.fn(); const mockStateUuid = 'mock-state-uuid'; const mockCodeVerifier = 'mock-code-verifier'; const mockCodeChallenge = 'mock-code-challenge'; @@ -68,13 +79,16 @@ describe('useCardProviderAuthentication', () => { mockGenerateState.mockReturnValue(mockStateUuid); mockUseCardSDK.mockReturnValue({ sdk: mockSdk as unknown as CardSDK, - isAuthenticated: false, - setIsAuthenticated: mockSetIsAuthenticated, isLoading: false, logoutFromProvider: jest.fn(), userCardLocation: 'international', }); mockStrings.mockImplementation((key: string) => `mocked_${key}`); + mockUseDispatch.mockReturnValue(mockDispatch); + mockSetIsAuthenticatedCard.mockReturnValue({ + type: 'card/setIsAuthenticatedCard', + payload: true, + } as ReturnType); }); describe('initial state', () => { @@ -160,7 +174,11 @@ describe('useCardProviderAuthentication', () => { expiresAt: expect.any(Number), location: loginParams.location, }); - expect(mockSetIsAuthenticated).toHaveBeenCalledWith(true); + expect(mockSetIsAuthenticatedCard).toHaveBeenCalledWith(true); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'card/setIsAuthenticatedCard', + payload: true, + }); expect(result.current.error).toBeNull(); expect(result.current.loading).toBe(false); }); @@ -360,8 +378,6 @@ describe('useCardProviderAuthentication', () => { it('throws error when SDK is not initialized', async () => { mockUseCardSDK.mockReturnValue({ sdk: null, - isAuthenticated: false, - setIsAuthenticated: mockSetIsAuthenticated, isLoading: false, logoutFromProvider: jest.fn(), userCardLocation: 'international', diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts index 4c12874d4008..0df9768c1358 100644 --- a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts +++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts @@ -4,6 +4,8 @@ import { storeCardBaanxToken } from '../util/cardTokenVault'; import { generatePKCEPair, generateState } from '../util/pkceHelpers'; import { CardError, CardErrorType, CardLocation } from '../types'; import { strings } from '../../../../../locales/i18n'; +import { useDispatch } from 'react-redux'; +import { setIsAuthenticatedCard as setIsAuthenticatedAction } from '../../../../core/redux/slices/card'; /** * Maps CardError types to user-friendly localized error messages @@ -45,9 +47,10 @@ const useCardProviderAuthentication = (): { error: string | null; clearError: () => void; } => { + const dispatch = useDispatch(); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const { sdk, setIsAuthenticated } = useCardSDK(); + const { sdk } = useCardSDK(); const clearError = useCallback(() => { setError(null); @@ -105,7 +108,7 @@ const useCardProviderAuthentication = (): { }); setError(null); - setIsAuthenticated(true); + dispatch(setIsAuthenticatedAction(true)); } catch (err) { const errorMessage = getErrorMessage(err); setError(errorMessage); @@ -115,7 +118,7 @@ const useCardProviderAuthentication = (): { setLoading(false); } }, - [sdk, setIsAuthenticated], + [sdk, dispatch], ); return useMemo( diff --git a/app/components/UI/Card/hooks/useCardholderCheck.test.ts b/app/components/UI/Card/hooks/useCardholderCheck.test.ts index 23d0358cca16..fa80fffb2f5d 100644 --- a/app/components/UI/Card/hooks/useCardholderCheck.test.ts +++ b/app/components/UI/Card/hooks/useCardholderCheck.test.ts @@ -55,11 +55,13 @@ describe('useCardholderCheck', () => { accounts: [ { type: 'eip155:eoa', - caipAccountId: 'eip155:1:0x123', + address: '0x123', + scopes: ['eip155:59144'], }, { type: 'eip155:eoa', - caipAccountId: 'eip155:1:0x456', + address: '0x456', + scopes: ['eip155:59144'], }, ], }; @@ -91,7 +93,7 @@ describe('useCardholderCheck', () => { expect(mockDispatch).toHaveBeenCalledWith( loadCardholderAccounts({ - caipAccountIds: ['eip155:1:0x123', 'eip155:1:0x456'], + caipAccountIds: ['eip155:0:0x123', 'eip155:0:0x456'], cardFeatureFlag: mockCardFeatureFlag, }), ); @@ -133,15 +135,18 @@ describe('useCardholderCheck', () => { const accountsWithNonEOA = [ { type: 'eip155:eoa', - caipAccountId: 'eip155:1:0x123', + address: '0x123', + scopes: ['eip155:59144'], }, { type: 'eip155:erc4337', - caipAccountId: 'eip155:1:0x456', + address: '0x456', + scopes: ['eip155:59144'], }, { type: 'eip155:eoa', - caipAccountId: 'eip155:1:0x789', + address: '0x789', + scopes: ['eip155:59144'], }, ]; @@ -151,7 +156,7 @@ describe('useCardholderCheck', () => { expect(mockDispatch).toHaveBeenCalledWith( loadCardholderAccounts({ - caipAccountIds: ['eip155:1:0x123', 'eip155:1:0x789'], + caipAccountIds: ['eip155:0:0x123', 'eip155:0:0x789'], cardFeatureFlag: mockCardFeatureFlag, }), ); @@ -161,11 +166,13 @@ describe('useCardholderCheck', () => { const accountsWithoutEOA = [ { type: 'eip155:erc4337', - caipAccountId: 'eip155:1:0x123', + address: '0x123', + scopes: ['eip155:59144'], }, { type: 'eip155:erc4337', - caipAccountId: 'eip155:1:0x456', + address: '0x456', + scopes: ['eip155:59144'], }, ]; @@ -223,7 +230,8 @@ describe('useCardholderCheck', () => { const newAccounts = [ { type: 'eip155:eoa', - caipAccountId: 'eip155:1:0x999', + address: '0x999', + scopes: ['eip155:59144'], }, ]; setupMockSelectors({ accounts: newAccounts }); @@ -232,7 +240,7 @@ describe('useCardholderCheck', () => { expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenLastCalledWith( loadCardholderAccounts({ - caipAccountIds: ['eip155:1:0x999'], + caipAccountIds: ['eip155:0:0x999'], cardFeatureFlag: mockCardFeatureFlag, }), ); diff --git a/app/components/UI/Card/hooks/useCardholderCheck.ts b/app/components/UI/Card/hooks/useCardholderCheck.ts index b1ffb41b0fb6..3999ab3a0012 100644 --- a/app/components/UI/Card/hooks/useCardholderCheck.ts +++ b/app/components/UI/Card/hooks/useCardholderCheck.ts @@ -1,7 +1,10 @@ import { useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; import useThunkDispatch from '../../../hooks/useThunkDispatch'; -import { selectCardFeatureFlag } from '../../../../selectors/featureFlagController/card'; +import { + CardFeatureFlag, + selectCardFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; import { loadCardholderAccounts } from '../../../../core/redux/slices/card'; import { selectAppServicesReady, @@ -40,7 +43,7 @@ export const useCardholderCheck = () => { dispatch( loadCardholderAccounts({ caipAccountIds, - cardFeatureFlag, + cardFeatureFlag: cardFeatureFlag as CardFeatureFlag, }), ); }, [cardFeatureFlag, dispatch, internalAccounts]); diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts index 983f765dc1df..7b09ed7fee73 100644 --- a/app/components/UI/Card/sdk/CardSDK.test.ts +++ b/app/components/UI/Card/sdk/CardSDK.test.ts @@ -525,44 +525,84 @@ describe('CardSDK', () => { }); describe('getGeoLocation', () => { - it('should return geolocation on successful API call', async () => { - const mockGeolocation = 'US'; - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: jest.fn().mockResolvedValue(mockGeolocation), - }); + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + // Restore original NODE_ENV + if (originalNodeEnv === undefined) { + delete (process.env as { NODE_ENV?: string }).NODE_ENV; + } else { + (process.env as { NODE_ENV?: string }).NODE_ENV = originalNodeEnv; + } + jest.clearAllMocks(); + }); + + it('should return UNKNOWN when API call fails', async () => { + const error = new Error('Network error'); + (global.fetch as jest.Mock).mockRejectedValueOnce(error); const result = await cardSDK.getGeoLocation(); - expect(result).toBe(mockGeolocation); - expect(global.fetch).toHaveBeenCalledWith( - new URL( - 'geolocation', - mockCardFeatureFlag.constants?.onRampApiUrl || '', - ), + + expect(result).toBe('UNKNOWN'); + expect(Logger.log).toHaveBeenCalledWith( + error, + 'CardSDK: Failed to get geolocation', ); }); - it('should handle API errors and return empty string', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - }); + it('should return UNKNOWN when fetch throws an error', async () => { + const fetchError = new Error('Fetch failed'); + (global.fetch as jest.Mock).mockRejectedValueOnce(fetchError); const result = await cardSDK.getGeoLocation(); - expect(result).toBe(''); - expect(Logger.log).toHaveBeenCalled(); + + expect(result).toBe('UNKNOWN'); + expect(Logger.log).toHaveBeenCalledWith( + fetchError, + 'CardSDK: Failed to get geolocation', + ); }); - it('should handle network errors and return empty string', async () => { - const error = new Error('Network error'); - (global.fetch as jest.Mock).mockRejectedValue(error); + it('should return UNKNOWN when response.text() throws an error', async () => { + const textError = new Error('Failed to read response text'); + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockRejectedValue(textError), + }); const result = await cardSDK.getGeoLocation(); - expect(result).toBe(''); + + expect(result).toBe('UNKNOWN'); expect(Logger.log).toHaveBeenCalledWith( - error, + textError, 'CardSDK: Failed to get geolocation', ); }); + + it('should handle different country codes correctly', async () => { + const countryCodes = ['US', 'GB', 'CA', 'DE', 'FR', 'UNKNOWN']; + + for (const code of countryCodes) { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValue(code), + }); + + const result = await cardSDK.getGeoLocation(); + expect(result).toBe(code); + } + }); + + it('should handle empty string response from API', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: jest.fn().mockResolvedValue(''), + }); + + const result = await cardSDK.getGeoLocation(); + + expect(result).toBe(''); + }); }); describe('getSupportedTokensAllowances', () => { diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts index 8aaa70eafd78..c9fdb73cde08 100644 --- a/app/components/UI/Card/sdk/CardSDK.ts +++ b/app/components/UI/Card/sdk/CardSDK.ts @@ -121,16 +121,6 @@ export class CardSDK { ); } - private get rampApiUrl() { - const onRampApi = this.cardFeatureFlag.constants?.onRampApiUrl; - - if (!onRampApi) { - throw new Error('On Ramp API URL is not defined for the current chain'); - } - - return onRampApi; - } - private get accountsApiUrl() { const accountsApi = this.cardFeatureFlag.constants?.accountsApiUrl; @@ -264,17 +254,24 @@ export class CardSDK { getGeoLocation = async (): Promise => { try { - const url = new URL('geolocation', this.rampApiUrl); + const env = process.env.NODE_ENV ?? 'production'; + const environment = env === 'production' ? 'PROD' : 'DEV'; + + const GEOLOCATION_URLS = { + DEV: 'https://on-ramp.dev-api.cx.metamask.io/geolocation', + PROD: 'https://on-ramp.api.cx.metamask.io/geolocation', + }; + const url = GEOLOCATION_URLS[environment]; const response = await fetch(url); if (!response.ok) { - throw new Error('Failed to fetch geolocation'); + throw new Error(`Failed to get geolocation: ${response.statusText}`); } return await response.text(); } catch (error) { Logger.log(error as Error, 'CardSDK: Failed to get geolocation'); - return ''; + return 'UNKNOWN'; } }; diff --git a/app/components/UI/Card/sdk/index.test.tsx b/app/components/UI/Card/sdk/index.test.tsx index 038244444e44..6dc61c44c62b 100644 --- a/app/components/UI/Card/sdk/index.test.tsx +++ b/app/components/UI/Card/sdk/index.test.tsx @@ -18,7 +18,6 @@ import { SupportedToken, selectCardFeatureFlag, } from '../../../../selectors/featureFlagController/card'; -import { selectChainId } from '../../../../selectors/networkController'; import { useCardholderCheck } from '../hooks/useCardholderCheck'; import { getCardBaanxToken, @@ -41,18 +40,24 @@ jest.mock('./CardSDK', () => ({ })), })); -jest.mock('../../../../selectors/networkController', () => ({ - selectChainId: jest.fn(), -})); - jest.mock('../../../../selectors/featureFlagController/card', () => ({ selectCardFeatureFlag: jest.fn(), + selectCardExperimentalSwitch: jest.fn(() => false), + selectCardSupportedCountries: jest.fn(() => []), + selectDisplayCardButtonFeatureFlag: jest.fn(() => false), +})); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(() => () => null), })); -// Mock react-redux hooks +// Create a stable mock dispatch function to prevent useEffect retriggering +const mockDispatch = jest.fn(); + jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), + useDispatch: jest.fn(() => mockDispatch), })); jest.mock('../hooks/useCardholderCheck', () => ({ @@ -72,7 +77,6 @@ jest.mock('../../../../util/Logger', () => ({ describe('CardSDK Context', () => { const MockedCardholderSDK = jest.mocked(CardSDK); const mockUseSelector = jest.mocked(useSelector); - const mockSelectChainId = jest.mocked(selectChainId); const mockSelectCardFeatureFlag = jest.mocked(selectCardFeatureFlag); const mockUseCardholderCheck = jest.mocked(useCardholderCheck); const mockGetCardBaanxToken = jest.mocked(getCardBaanxToken); @@ -102,10 +106,52 @@ describe('CardSDK Context', () => { }, }; + // Helper: Setup feature flag selector + const setupMockUseSelector = ( + featureFlag: CardFeatureFlag | null | undefined | Record, + ) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === mockSelectCardFeatureFlag) { + return featureFlag; + } + return null; + }); + }; + + // Helper: Create mock SDK with custom properties + const createMockSDK = ( + overrides: Partial = {}, + ): Partial => ({ + isCardEnabled: true, + isBaanxLoginEnabled: true, + supportedTokens: [], + isCardHolder: jest.fn(), + getGeoLocation: jest.fn(), + getSupportedTokensAllowances: jest.fn(), + getPriorityToken: jest.fn(), + refreshLocalToken: jest.fn().mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + }), + ...overrides, + }); + + // Helper: Setup SDK mock + const setupMockSDK = (sdkProperties: Partial = {}) => { + MockedCardholderSDK.mockImplementation( + () => createMockSDK(sdkProperties) as CardSDK, + ); + }; + + // Helper: Create wrapper component + const createWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + beforeEach(() => { jest.clearAllMocks(); MockedCardholderSDK.mockClear(); - mockSelectChainId.mockClear(); mockSelectCardFeatureFlag.mockClear(); mockUseSelector.mockClear(); mockUseCardholderCheck.mockClear(); @@ -113,8 +159,9 @@ describe('CardSDK Context', () => { mockStoreCardBaanxToken.mockClear(); mockRemoveCardBaanxToken.mockClear(); mockLogger.log.mockClear(); + mockDispatch.mockClear(); - // Default successful token retrieval + // Default: no token found mockGetCardBaanxToken.mockResolvedValue({ success: false, error: 'No token found', @@ -123,60 +170,50 @@ describe('CardSDK Context', () => { mockRemoveCardBaanxToken.mockResolvedValue({ success: true }); }); - const setupMockUseSelector = ( - featureFlag: CardFeatureFlag | null | undefined, - ) => { - mockUseSelector.mockImplementation((selector) => { - if (selector === mockSelectCardFeatureFlag) { - return featureFlag; - } - return null; - }); - }; - describe('CardSDKProvider', () => { - it('should render children without crashing', () => { + it('initializes SDK when feature flag is available', () => { + // Given: feature flag is configured setupMockUseSelector(mockCardFeatureFlag); - const TestComponent = () => Test Child; - + // When: provider renders render( - + Test Child , ); + // Then: SDK should be created with feature flag expect(MockedCardholderSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, }); }); - it('should not initialize SDK when card feature flag is missing', () => { + it('does not initialize SDK when feature flag is missing', () => { + // Given: no feature flag setupMockUseSelector(null); - const TestComponent = () => Test Child; - + // When: provider renders render( - + Test Child , ); + // Then: SDK should not be created expect(MockedCardholderSDK).not.toHaveBeenCalled(); }); - it('should use provided value prop when given', () => { + it('uses provided value prop when given', () => { + // Given: a custom context value setupMockUseSelector(mockCardFeatureFlag); - const providedValue: ICardSDK = { sdk: null, - isAuthenticated: false, - setIsAuthenticated: jest.fn(), isLoading: false, logoutFromProvider: jest.fn(), userCardLocation: 'international' as const, }; + // When: provider renders with custom value const TestComponent = () => { const context = useCardSDK(); expect(context).toEqual(providedValue); @@ -188,48 +225,44 @@ describe('CardSDK Context', () => { , ); + + // Then: custom value is used (assertion in TestComponent) }); }); describe('useCardSDK', () => { - it('should return SDK context when used within provider', async () => { + it('returns SDK context when used within provider', async () => { + // Given: provider with feature flag setupMockUseSelector(mockCardFeatureFlag); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: hook is called within provider + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should return valid context await waitFor(() => { expect(result.current.sdk).not.toBeNull(); }); expect(result.current).toEqual({ sdk: expect.any(Object), - isAuthenticated: expect.any(Boolean), - setIsAuthenticated: expect.any(Function), isLoading: expect.any(Boolean), logoutFromProvider: expect.any(Function), userCardLocation: expect.stringMatching(/^(us|international)$/), }); - expect(result.current.sdk).toHaveProperty('isCardEnabled', true); - expect(result.current.sdk).toHaveProperty('isBaanxLoginEnabled', true); - expect(result.current.sdk).toHaveProperty('supportedTokens', []); - expect(result.current.sdk).toHaveProperty('isCardHolder'); - expect(result.current.sdk).toHaveProperty('getGeoLocation'); - expect(result.current.sdk).toHaveProperty('getSupportedTokensAllowances'); - expect(result.current.sdk).toHaveProperty('getPriorityToken'); - expect(result.current.sdk).toHaveProperty('refreshLocalToken'); }); - it('should throw error when used outside of provider', () => { + it('throws error when used outside provider', () => { + // Given: no provider const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => { // Suppress console error for test }); + // When: hook is called without provider + // Then: should throw error expect(() => { renderHook(() => useCardSDK()); }).toThrow('useCardSDK must be used within a CardSDKProvider'); @@ -238,72 +271,6 @@ describe('CardSDK Context', () => { }); }); - describe('CardSDK interface', () => { - it('should have correct interface structure', async () => { - setupMockUseSelector(mockCardFeatureFlag); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); - - await waitFor(() => { - expect(result.current.sdk).not.toBeNull(); - }); - - expect(result.current).toHaveProperty('sdk'); - expect( - typeof result.current.sdk === 'object' || result.current.sdk === null, - ).toBe(true); - - if (result.current.sdk) { - expect(result.current.sdk).toHaveProperty('isCardEnabled'); - expect(result.current.sdk).toHaveProperty('isBaanxLoginEnabled'); - expect(result.current.sdk).toHaveProperty('supportedTokens'); - expect(result.current.sdk).toHaveProperty('isCardHolder'); - expect(result.current.sdk).toHaveProperty('getGeoLocation'); - expect(result.current.sdk).toHaveProperty( - 'getSupportedTokensAllowances', - ); - expect(result.current.sdk).toHaveProperty('getPriorityToken'); - expect(result.current.sdk).toHaveProperty('refreshLocalToken'); - } - }); - }); - - describe('Edge cases', () => { - it('should handle undefined card feature flag gracefully', () => { - setupMockUseSelector(undefined); - - const TestComponent = () => Test Child; - - render( - - - , - ); - - expect(MockedCardholderSDK).not.toHaveBeenCalled(); - }); - - it('should handle empty card feature flag gracefully', () => { - setupMockUseSelector({}); - - const TestComponent = () => Test Child; - - render( - - - , - ); - - expect(MockedCardholderSDK).toHaveBeenCalledWith({ - cardFeatureFlag: {}, - }); - }); - }); - describe('Authentication Logic', () => { const mockValidTokenData = { accessToken: 'valid-access-token', @@ -319,42 +286,22 @@ describe('CardSDK Context', () => { location: 'us' as const, }; - beforeEach(() => { - // Mock SDK with Baanx login enabled - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn().mockResolvedValue({ - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - expiresIn: 3600, - }), - } as unknown as CardSDK), - ); - }); - - it('should authenticate user with valid token', async () => { + it('authenticates user with valid token', async () => { + // Given: valid token available + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, tokenData: mockValidTokenData, }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: user should be authenticated await waitFor(() => { - expect(result.current.isAuthenticated).toBe(true); expect(result.current.userCardLocation).toBe('international'); expect(result.current.isLoading).toBe(false); }); @@ -362,21 +309,22 @@ describe('CardSDK Context', () => { expect(mockGetCardBaanxToken).toHaveBeenCalled(); }); - it('should not authenticate user when token retrieval fails', async () => { + it('logs error when token retrieval fails', async () => { + // Given: token retrieval fails + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: false, error: 'Keychain error', }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should log error and not authenticate await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); @@ -386,40 +334,42 @@ describe('CardSDK Context', () => { ); }); - it('should not authenticate user when no token data exists', async () => { + it('does not authenticate when token data is missing', async () => { + // Given: no token data + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, tokenData: undefined, }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should not authenticate await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); }); - it('should refresh expired token successfully', async () => { + it('refreshes expired token successfully', async () => { + // Given: expired token available + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, tokenData: mockExpiredTokenData, }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: token should be refreshed and stored await waitFor(() => { - expect(result.current.isAuthenticated).toBe(true); expect(result.current.userCardLocation).toBe('us'); expect(result.current.isLoading).toBe(false); }); @@ -432,38 +382,26 @@ describe('CardSDK Context', () => { }); }); - it('should handle token refresh failure', async () => { + it('logs error when token refresh fails', async () => { + // Given: expired token and refresh failure + setupMockSDK({ + refreshLocalToken: jest + .fn() + .mockRejectedValue(new Error('Refresh failed')), + }); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, tokenData: mockExpiredTokenData, }); - // Mock refresh token failure - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest - .fn() - .mockRejectedValue(new Error('Refresh failed')), - } as unknown as CardSDK), - ); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should log error await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); @@ -473,49 +411,37 @@ describe('CardSDK Context', () => { ); }); - it('should skip authentication when Baanx login is disabled', async () => { + it('skips authentication when Baanx login is disabled', async () => { + // Given: Baanx login disabled + setupMockSDK({ isBaanxLoginEnabled: false }); setupMockUseSelector(mockCardFeatureFlag); - // Mock SDK with Baanx login disabled - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: false, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn(), - } as unknown as CardSDK), - ); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should not attempt authentication await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); expect(mockGetCardBaanxToken).not.toHaveBeenCalled(); }); - it('should handle authentication errors gracefully', async () => { + it('handles authentication errors gracefully', async () => { + // Given: authentication throws error + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockRejectedValue(new Error('Unexpected error')); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should log error and complete loading await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); @@ -527,23 +453,9 @@ describe('CardSDK Context', () => { }); describe('Logout Functionality', () => { - beforeEach(() => { - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn(), - } as unknown as CardSDK), - ); - }); - - it('should logout user successfully', async () => { + it('logs out user successfully', async () => { + // Given: authenticated user + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, @@ -555,39 +467,37 @@ describe('CardSDK Context', () => { }, }); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); - // Wait for initial authentication await waitFor(() => { - expect(result.current.isAuthenticated).toBe(true); + expect(result.current.isLoading).toBe(false); }); - // Perform logout + // When: user logs out await act(async () => { await result.current.logoutFromProvider(); }); - expect(result.current.isAuthenticated).toBe(false); + // Then: token should be removed expect(mockRemoveCardBaanxToken).toHaveBeenCalled(); }); - it('should throw error when SDK is not available for logout', async () => { - setupMockUseSelector(null); // No feature flag = no SDK - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); + it('throws error when SDK is unavailable for logout', async () => { + // Given: no SDK available + setupMockUseSelector(null); - const { result } = renderHook(() => useCardSDK(), { wrapper }); + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); await waitFor(() => { expect(result.current.sdk).toBeNull(); }); + // When: attempting logout + // Then: should throw error await expect(result.current.logoutFromProvider()).rejects.toThrow( 'SDK not available for logout', ); @@ -595,25 +505,10 @@ describe('CardSDK Context', () => { }); describe('Loading States', () => { - beforeEach(() => { - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn(), - } as unknown as CardSDK), - ); - }); - - it('should show loading state during authentication', async () => { + it('shows loading state during authentication', async () => { + // Given: slow token retrieval + setupMockSDK(); setupMockUseSelector(mockCardFeatureFlag); - // Delay token retrieval to capture loading state mockGetCardBaanxToken.mockImplementation( () => new Promise((resolve) => @@ -633,74 +528,49 @@ describe('CardSDK Context', () => { ), ); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); - // Should start with loading state + // Then: should show loading initially expect(result.current.isLoading).toBe(true); - expect(result.current.isAuthenticated).toBe(false); - // Wait for authentication to complete + // And: loading should complete await waitFor(() => { expect(result.current.isLoading).toBe(false); - expect(result.current.isAuthenticated).toBe(true); }); }); - it('should not show loading when Baanx login is disabled', async () => { + it('does not show loading when Baanx login is disabled', async () => { + // Given: Baanx login disabled + setupMockSDK({ isBaanxLoginEnabled: false }); setupMockUseSelector(mockCardFeatureFlag); - // Mock SDK with Baanx login disabled - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: false, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn(), - } as unknown as CardSDK), - ); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should not be loading await waitFor(() => { expect(result.current.isLoading).toBe(false); - expect(result.current.isAuthenticated).toBe(false); }); - // Should never have been in loading state expect(mockGetCardBaanxToken).not.toHaveBeenCalled(); }); }); describe('Token Refresh Edge Cases', () => { - beforeEach(() => { - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn(), - } as unknown as CardSDK), - ); - }); - - it('should handle invalid token response during refresh', async () => { + it('handles invalid token response during refresh', async () => { + // Given: expired token and invalid refresh response + setupMockSDK({ + refreshLocalToken: jest.fn().mockResolvedValue({ + accessToken: null, // Invalid response + refreshToken: 'new-refresh-token', + expiresIn: 3600, + }), + }); setupMockUseSelector(mockCardFeatureFlag); mockGetCardBaanxToken.mockResolvedValue({ success: true, @@ -712,33 +582,13 @@ describe('CardSDK Context', () => { }, }); - // Mock invalid refresh response - MockedCardholderSDK.mockImplementation( - () => - ({ - isCardEnabled: true, - isBaanxLoginEnabled: true, - supportedTokens: [], - isCardHolder: jest.fn(), - getGeoLocation: jest.fn(), - getSupportedTokensAllowances: jest.fn(), - getPriorityToken: jest.fn(), - refreshLocalToken: jest.fn().mockResolvedValue({ - accessToken: null, // Invalid response - refreshToken: 'new-refresh-token', - expiresIn: 3600, - }), - } as unknown as CardSDK), - ); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); + // When: provider initializes + const { result } = renderHook(() => useCardSDK(), { + wrapper: createWrapper, + }); + // Then: should log error and not store invalid token await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); expect(result.current.isLoading).toBe(false); }); @@ -748,75 +598,23 @@ describe('CardSDK Context', () => { ); expect(mockStoreCardBaanxToken).not.toHaveBeenCalled(); }); - - it('should handle SDK unavailable during token refresh', async () => { - setupMockUseSelector(mockCardFeatureFlag); - mockGetCardBaanxToken.mockResolvedValue({ - success: true, - tokenData: { - accessToken: 'expired-access-token', - refreshToken: 'expired-refresh-token', - expiresAt: Date.now() - 3600000, - location: 'international' as const, - }, - }); - - // Create provider but force SDK to be null during refresh - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useCardSDK(), { wrapper }); - - // Mock SDK becoming null during refresh (edge case) - const originalSdk = result.current.sdk; - if (originalSdk) { - (originalSdk as CardSDK).refreshLocalToken = jest - .fn() - .mockImplementation(() => { - // Simulate SDK becoming unavailable during refresh - throw new Error('SDK not available for token refresh'); - }); - } - - await waitFor(() => { - expect(result.current.isAuthenticated).toBe(false); - expect(result.current.isLoading).toBe(false); - }); - }); }); describe('CardVerification', () => { - beforeEach(() => { - mockUseCardholderCheck.mockClear(); - }); - - it('should render without crashing', () => { - const result = render(); - expect(result).toBeTruthy(); - }); - - it('should call useCardholderCheck hook', () => { + it('calls useCardholderCheck hook', () => { + // When: component renders render(); + + // Then: should call cardholder check expect(mockUseCardholderCheck).toHaveBeenCalledTimes(1); }); - it('should return null (render nothing)', () => { + it('renders nothing', () => { + // When: component renders const { toJSON } = render(); - expect(toJSON()).toBeNull(); - }); - it('should call useCardholderCheck on every render', () => { - const { rerender } = render(); - expect(mockUseCardholderCheck).toHaveBeenCalledTimes(1); - - rerender(); - expect(mockUseCardholderCheck).toHaveBeenCalledTimes(2); - }); - - it('should be a functional component', () => { - expect(typeof CardVerification).toBe('function'); - expect(CardVerification.prototype).toEqual({}); + // Then: should return null + expect(toJSON()).toBeNull(); }); }); }); diff --git a/app/components/UI/Card/sdk/index.tsx b/app/components/UI/Card/sdk/index.tsx index 0ea103731788..ebf6bb11ccec 100644 --- a/app/components/UI/Card/sdk/index.tsx +++ b/app/components/UI/Card/sdk/index.tsx @@ -6,10 +6,13 @@ import React, { useEffect, useCallback, } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { CardSDK } from './CardSDK'; -import { selectCardFeatureFlag } from '../../../../selectors/featureFlagController/card'; +import { + CardFeatureFlag, + selectCardFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; import { useCardholderCheck } from '../hooks/useCardholderCheck'; import { getCardBaanxToken, @@ -17,12 +20,11 @@ import { storeCardBaanxToken, } from '../util/cardTokenVault'; import Logger from '../../../../util/Logger'; +import { setIsAuthenticatedCard } from '../../../../core/redux/slices/card'; // Types export interface ICardSDK { sdk: CardSDK | null; - isAuthenticated: boolean; - setIsAuthenticated: (isAuthenticated: boolean) => void; isLoading: boolean; logoutFromProvider: () => Promise; userCardLocation: 'us' | 'international'; @@ -45,8 +47,8 @@ export const CardSDKProvider = ({ ...props }: ProviderProps) => { const cardFeatureFlag = useSelector(selectCardFeatureFlag); + const dispatch = useDispatch(); const [sdk, setSdk] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(false); const [userCardLocation, setUserCardLocation] = useState< 'us' | 'international' >('international'); @@ -57,7 +59,9 @@ export const CardSDKProvider = ({ // Initialize CardSDK when feature flag is enabled useEffect(() => { if (cardFeatureFlag) { - const cardSDK = new CardSDK({ cardFeatureFlag }); + const cardSDK = new CardSDK({ + cardFeatureFlag: cardFeatureFlag as CardFeatureFlag, + }); setSdk(cardSDK); } else { setSdk(null); @@ -87,44 +91,43 @@ export const CardSDKProvider = ({ location, }); - setIsAuthenticated(true); + dispatch(setIsAuthenticatedCard(true)); setUserCardLocation(location); } catch (error) { Logger.log('Token refresh failed:', error); - setIsAuthenticated(false); + dispatch(setIsAuthenticatedCard(false)); } }, - [sdk], + [sdk, dispatch], ); const handleTokenAuthentication = useCallback(async (): Promise => { const tokenResult = await getCardBaanxToken(); // If token retrieval failed, user is not authenticated - if (!tokenResult.success) { + if ( + !tokenResult.success || + !tokenResult.tokenData?.accessToken || + !tokenResult.tokenData?.refreshToken || + !tokenResult.tokenData?.expiresAt || + !tokenResult.tokenData?.location + ) { Logger.log('Token retrieval failed:', tokenResult.error); - setIsAuthenticated(false); + dispatch(setIsAuthenticatedCard(false)); return; } - const { accessToken, refreshToken, expiresAt, location } = - tokenResult.tokenData || {}; - - // If no token data exists, user needs to authenticate - if (!accessToken || !refreshToken || !expiresAt || !location) { - setIsAuthenticated(false); - return; - } + const { refreshToken, expiresAt, location } = tokenResult.tokenData; // If token is still valid, user is authenticated if (Date.now() < expiresAt) { - setIsAuthenticated(true); + dispatch(setIsAuthenticatedCard(true)); setUserCardLocation(location); return; } await attemptTokenRefresh(refreshToken, location); - }, [attemptTokenRefresh]); + }, [attemptTokenRefresh, dispatch]); // Check authentication status and handle token refresh useEffect(() => { @@ -135,7 +138,7 @@ export const CardSDKProvider = ({ await handleTokenAuthentication(); } catch (error) { Logger.log('Authentication check failed:', error); - setIsAuthenticated(false); + dispatch(setIsAuthenticatedCard(false)); } finally { setIsLoading(false); } @@ -146,9 +149,9 @@ export const CardSDKProvider = ({ authenticateUser(); } else { setIsLoading(false); - setIsAuthenticated(false); + dispatch(setIsAuthenticatedCard(false)); } - }, [isBaanxLoginEnabled, handleTokenAuthentication]); + }, [isBaanxLoginEnabled, handleTokenAuthentication, dispatch]); const logoutFromProvider = useCallback(async () => { if (!sdk) { @@ -156,27 +159,18 @@ export const CardSDKProvider = ({ } await removeCardBaanxToken(); - setIsAuthenticated(false); - }, [sdk]); + dispatch(setIsAuthenticatedCard(false)); + }, [sdk, dispatch]); // Memoized context value to prevent unnecessary re-renders const contextValue = useMemo( (): ICardSDK => ({ sdk, - isAuthenticated, - setIsAuthenticated, isLoading, logoutFromProvider, userCardLocation, }), - [ - sdk, - isAuthenticated, - setIsAuthenticated, - isLoading, - logoutFromProvider, - userCardLocation, - ], + [sdk, isLoading, logoutFromProvider, userCardLocation], ); return ; diff --git a/app/components/UI/Card/util/getCardholder.test.ts b/app/components/UI/Card/util/getCardholder.test.ts index 457c2ccacdb9..0f39eb5f044d 100644 --- a/app/components/UI/Card/util/getCardholder.test.ts +++ b/app/components/UI/Card/util/getCardholder.test.ts @@ -57,38 +57,47 @@ describe('getCardholder', () => { mockCardSDKInstance = { isCardHolder: jest.fn(), + getGeoLocation: jest.fn(), } as unknown as jest.Mocked; MockedCardSDK.mockImplementation(() => mockCardSDKInstance); // Mock address utilities mockedIsValidHexAddress.mockReturnValue(true); + + // Default mock for geolocation + mockCardSDKInstance.getGeoLocation.mockResolvedValue('US'); }); describe('successful scenarios', () => { - it('should return cardholder addresses when accounts are cardholders', async () => { + it('should return cardholder addresses and geolocation when accounts are cardholders', async () => { const mockResult = [ 'eip155:59144:0x1234567890abcdef1234567890abcdef12345678', 'eip155:59144:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', ] as `${string}:${string}:${string}`[]; mockCardSDKInstance.isCardHolder.mockResolvedValue(mockResult); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('US'); const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([ - '0x1234567890abcdef1234567890abcdef12345678', - '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - ]); + expect(result).toEqual({ + cardholderAddresses: [ + '0x1234567890abcdef1234567890abcdef12345678', + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ], + geoLocation: 'US', + }); expect(MockedCardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, }); expect(mockCardSDKInstance.isCardHolder).toHaveBeenCalledWith( mockFormattedAccounts, ); + expect(mockCardSDKInstance.getGeoLocation).toHaveBeenCalled(); }); it('should return only cardholder addresses from mixed results', async () => { @@ -97,80 +106,103 @@ describe('getCardholder', () => { ] as `${string}:${string}:${string}`[]; mockCardSDKInstance.isCardHolder.mockResolvedValue(mockResult); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('GB'); const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual(['0x1234567890abcdef1234567890abcdef12345678']); + expect(result).toEqual({ + cardholderAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], + geoLocation: 'GB', + }); }); - it('should return empty array when no accounts are cardholders', async () => { + it('should return empty array and geolocation when no accounts are cardholders', async () => { mockCardSDKInstance.isCardHolder.mockResolvedValue([]); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('CA'); const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'CA', + }); }); }); describe('early return scenarios', () => { - it('should return empty array when cardFeatureFlag is null', async () => { + it('should return empty array and UNKNOWN geolocation when cardFeatureFlag is null', async () => { const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: null as unknown as CardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(MockedCardSDK).not.toHaveBeenCalled(); expect(mockCardSDKInstance.isCardHolder).not.toHaveBeenCalled(); }); - it('should return empty array when cardFeatureFlag is undefined', async () => { + it('should return empty array and UNKNOWN geolocation when cardFeatureFlag is undefined', async () => { const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: undefined as unknown as CardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(MockedCardSDK).not.toHaveBeenCalled(); expect(mockCardSDKInstance.isCardHolder).not.toHaveBeenCalled(); }); - it('should return empty array when caipAccountIds is empty', async () => { + it('should return empty array and UNKNOWN geolocation when caipAccountIds is empty', async () => { const result = await getCardholder({ caipAccountIds: [], cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(MockedCardSDK).not.toHaveBeenCalled(); expect(mockCardSDKInstance.isCardHolder).not.toHaveBeenCalled(); }); - it('should return empty array when caipAccountIds is null', async () => { + it('should return empty array and UNKNOWN geolocation when caipAccountIds is null', async () => { const result = await getCardholder({ caipAccountIds: null as unknown as `${string}:${string}:${string}`[], cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(MockedCardSDK).not.toHaveBeenCalled(); expect(mockCardSDKInstance.isCardHolder).not.toHaveBeenCalled(); }); - it('should return empty array when caipAccountIds is undefined', async () => { + it('should return empty array and UNKNOWN geolocation when caipAccountIds is undefined', async () => { const result = await getCardholder({ caipAccountIds: undefined as unknown as `${string}:${string}:${string}`[], cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(MockedCardSDK).not.toHaveBeenCalled(); expect(mockCardSDKInstance.isCardHolder).not.toHaveBeenCalled(); }); @@ -188,7 +220,10 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(mockedLogger.error).toHaveBeenCalledWith( mockError, 'getCardholder::Error loading cardholder accounts', @@ -204,7 +239,10 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(mockedLogger.error).toHaveBeenCalledWith( mockError, 'getCardholder::Error loading cardholder accounts', @@ -220,7 +258,10 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(mockedLogger.error).toHaveBeenCalledWith( new Error(mockErrorString), 'getCardholder::Error loading cardholder accounts', @@ -235,7 +276,10 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(mockedLogger.error).toHaveBeenCalledWith( new Error('null'), 'getCardholder::Error loading cardholder accounts', @@ -250,7 +294,10 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([]); + expect(result).toEqual({ + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }); expect(mockedLogger.error).toHaveBeenCalledWith( new Error('undefined'), 'getCardholder::Error loading cardholder accounts', @@ -267,20 +314,24 @@ describe('getCardholder', () => { ] as `${string}:${string}:${string}`[]; mockCardSDKInstance.isCardHolder.mockResolvedValue(mockResult); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('DE'); const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([ - '0x1111111111111111111111111111111111111111', - '0x2222222222222222222222222222222222222222', - '0x3333333333333333333333333333333333333333', - ]); + expect(result).toEqual({ + cardholderAddresses: [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', + ], + geoLocation: 'DE', + }); }); - it('should handle invalid CAIP-10 format and log errors', async () => { + it('should handle invalid CAIP-10 format and filter them out', async () => { const mockResult = [ 'invalid:format', 'eip155:59144:0x1111111111111111111111111111111111111111', @@ -288,13 +339,17 @@ describe('getCardholder', () => { ] as `${string}:${string}:${string}`[]; mockCardSDKInstance.isCardHolder.mockResolvedValue(mockResult); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('FR'); const result = await getCardholder({ caipAccountIds: mockFormattedAccounts, cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual(['0x1111111111111111111111111111111111111111']); + expect(result).toEqual({ + cardholderAddresses: ['0x1111111111111111111111111111111111111111'], + geoLocation: 'FR', + }); }); it('should filter out invalid hex addresses', async () => { @@ -305,6 +360,7 @@ describe('getCardholder', () => { ] as `${string}:${string}:${string}`[]; mockCardSDKInstance.isCardHolder.mockResolvedValue(mockResult); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('ES'); mockedIsValidHexAddress .mockReturnValueOnce(true) .mockReturnValueOnce(false) @@ -315,11 +371,46 @@ describe('getCardholder', () => { cardFeatureFlag: mockCardFeatureFlag, }); - expect(result).toEqual([ - '0x1111111111111111111111111111111111111111', - '0x2222222222222222222222222222222222222222', - ]); + expect(result).toEqual({ + cardholderAddresses: [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + ], + geoLocation: 'ES', + }); expect(mockedIsValidHexAddress).toHaveBeenCalledTimes(3); }); }); + + describe('geolocation handling', () => { + it('should handle different geolocation values', async () => { + const geoLocations = ['US', 'GB', 'CA', 'DE', 'UNKNOWN']; + + for (const geoLocation of geoLocations) { + mockCardSDKInstance.isCardHolder.mockResolvedValue([ + 'eip155:59144:0x1234567890abcdef1234567890abcdef12345678', + ] as `${string}:${string}:${string}`[]); + mockCardSDKInstance.getGeoLocation.mockResolvedValue(geoLocation); + + const result = await getCardholder({ + caipAccountIds: mockFormattedAccounts, + cardFeatureFlag: mockCardFeatureFlag, + }); + + expect(result.geoLocation).toBe(geoLocation); + } + }); + + it('should call getGeoLocation for each request', async () => { + mockCardSDKInstance.isCardHolder.mockResolvedValue([]); + mockCardSDKInstance.getGeoLocation.mockResolvedValue('US'); + + await getCardholder({ + caipAccountIds: mockFormattedAccounts, + cardFeatureFlag: mockCardFeatureFlag, + }); + + expect(mockCardSDKInstance.getGeoLocation).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/Card/util/getCardholder.ts b/app/components/UI/Card/util/getCardholder.ts index 7af2d58cf94e..79926a99f5fd 100644 --- a/app/components/UI/Card/util/getCardholder.ts +++ b/app/components/UI/Card/util/getCardholder.ts @@ -10,10 +10,16 @@ export const getCardholder = async ({ }: { caipAccountIds: `${string}:${string}:${string}`[]; cardFeatureFlag: CardFeatureFlag | null; -}) => { +}): Promise<{ + cardholderAddresses: string[]; + geoLocation: string; +}> => { try { if (!cardFeatureFlag || !caipAccountIds?.length) { - return []; + return { + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }; } const cardSDK = new CardSDK({ @@ -21,6 +27,7 @@ export const getCardholder = async ({ }); const cardCaipAccountIds = await cardSDK.isCardHolder(caipAccountIds); + const geoLocation = await cardSDK.getGeoLocation(); const cardholderAddresses = cardCaipAccountIds.map((cardCaipAccountId) => { if (!isCaipAccountId(cardCaipAccountId)) return null; @@ -32,12 +39,18 @@ export const getCardholder = async ({ return address.toLowerCase(); }); - return cardholderAddresses.filter(Boolean) as string[]; + return { + cardholderAddresses: cardholderAddresses.filter(Boolean) as string[], + geoLocation, + }; } catch (error) { Logger.error( error instanceof Error ? error : new Error(String(error)), 'getCardholder::Error loading cardholder accounts', ); - return []; + return { + cardholderAddresses: [], + geoLocation: 'UNKNOWN', + }; } }; diff --git a/app/components/Views/Settings/ExperimentalSettings/index.test.tsx b/app/components/Views/Settings/ExperimentalSettings/index.test.tsx index 390eecd6754a..90e42b7e78df 100644 --- a/app/components/Views/Settings/ExperimentalSettings/index.test.tsx +++ b/app/components/Views/Settings/ExperimentalSettings/index.test.tsx @@ -45,6 +45,20 @@ jest.mock('react-native-device-info', () => ({ getBuildNumber: jest.fn(), })); +jest.mock('../../../../selectors/featureFlagController/card', () => ({ + selectCardExperimentalSwitch: jest.fn(() => false), +})); + +jest.mock('../../../../core/redux/slices/card', () => ({ + selectAlwaysShowCardButton: jest.fn( + (state) => state.card.alwaysShowCardButton, + ), + setAlwaysShowCardButton: jest.fn((value) => ({ + type: 'card/setAlwaysShowCardButton', + payload: value, + })), +})); + const mockStore = configureMockStore(); const initialState = { @@ -64,6 +78,11 @@ const initialState = { activeTraceBySessionId: {}, isInitialized: true, }, + card: { + alwaysShowCardButton: false, + isAuthenticatedCard: false, + cardholderAccounts: [], + }, engine: { backgroundState, }, diff --git a/app/components/Views/Settings/ExperimentalSettings/index.tsx b/app/components/Views/Settings/ExperimentalSettings/index.tsx index 1563e55279bd..240f79c867d7 100644 --- a/app/components/Views/Settings/ExperimentalSettings/index.tsx +++ b/app/components/Views/Settings/ExperimentalSettings/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { ScrollView, View } from 'react-native'; +import { ScrollView, Switch, View } from 'react-native'; import { strings } from '../../../../../locales/i18n'; import { useTheme } from '../../../../util/theme'; @@ -17,7 +17,7 @@ import Button, { } from '../../../../component-library/components/Buttons/Button'; import Routes from '../../../../../app/constants/navigation/Routes'; import { selectPerformanceMetrics } from '../../../../core/redux/slices/performance'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { isTest } from '../../../../util/test/utils'; import ReactNativeBlobUtil from 'react-native-blob-util'; import Share from 'react-native-share'; @@ -26,12 +26,20 @@ import { getVersion, getBuildNumber, } from 'react-native-device-info'; +import { + selectAlwaysShowCardButton, + setAlwaysShowCardButton, +} from '../../../../core/redux/slices/card'; +import { selectCardExperimentalSwitch } from '../../../../selectors/featureFlagController/card'; /** * Main view for app Experimental Settings */ const ExperimentalSettings = ({ navigation, route }: Props) => { + const dispatch = useDispatch(); const performanceMetrics = useSelector(selectPerformanceMetrics); + const cardExperimentalSwitch = useSelector(selectCardExperimentalSwitch); + const alwaysShowCardButton = useSelector(selectAlwaysShowCardButton); const isFullScreenModal = route?.params?.isFullScreenModal; @@ -82,6 +90,30 @@ const ExperimentalSettings = ({ navigation, route }: Props) => { ); + const handleAlwaysShowCardButtonToggle = (value: boolean) => { + dispatch(setAlwaysShowCardButton(value)); + }; + + const renderCardSettings = () => ( + + + {strings('experimental_settings.card_title')} + + + {strings('experimental_settings.card_desc')} + + + + ); + const downloadPerformanceMetrics = async () => { try { const appName = await getApplicationName(); @@ -132,6 +164,7 @@ const ExperimentalSettings = ({ navigation, route }: Props) => { return ( {renderWalletConnectSettings()} + {cardExperimentalSwitch && renderCardSettings()} {isTest && renderPerformanceSettings()} ); diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index a9cd4b25b6ab..8f1159e1c584 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -34,6 +34,7 @@ jest.mock('../../UI/Perps/Views/PerpsTabView', () => ({ // Mock remoteFeatureFlag util to ensure version check passes jest.mock('../../../util/remoteFeatureFlag', () => ({ hasMinimumRequiredVersion: jest.fn(() => true), + validatedVersionGatedFeatureFlag: jest.fn(() => false), })); jest.mock( diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index e1316b4212e7..ebbe54e273d0 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -158,7 +158,7 @@ import { IconColor, IconName, } from '../../../component-library/components/Icons/Icon'; -import { selectIsCardholder } from '../../../core/redux/slices/card'; +import { selectDisplayCardButton } from '../../../core/redux/slices/card'; import { selectIsConnectionRemoved } from '../../../reducers/user'; import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; @@ -1059,7 +1059,7 @@ const Wallet = ({ [navigation, chainId, evmNetworkConfigurations], ); - const isCardholder = useSelector(selectIsCardholder); + const shouldDisplayCardButton = useSelector(selectDisplayCardButton); const isRewardsEnabled = useSelector(selectRewardsEnabledFlag); useEffect(() => { @@ -1078,7 +1078,7 @@ const Wallet = ({ isBackupAndSyncEnabled, unreadNotificationCount, readNotificationCount, - isCardholder, + shouldDisplayCardButton, isRewardsEnabled, ), ); @@ -1094,7 +1094,7 @@ const Wallet = ({ isBackupAndSyncEnabled, unreadNotificationCount, readNotificationCount, - isCardholder, + shouldDisplayCardButton, isRewardsEnabled, ]); diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts index 77260e856806..e040135bb743 100644 --- a/app/core/redux/slices/card/index.test.ts +++ b/app/core/redux/slices/card/index.test.ts @@ -13,6 +13,10 @@ import cardReducer, { initialState, setHasViewedCardButton, selectHasViewedCardButton, + selectCardGeoLocation, + selectDisplayCardButton, + selectAlwaysShowCardButton, + setAlwaysShowCardButton, } from '.'; import { CardTokenAllowance, @@ -30,8 +34,20 @@ jest.mock('../../../Multichain/utils', () => ({ isEthAccount: jest.fn(), })); +// Mock feature flag selectors +jest.mock('../../../../selectors/featureFlagController/card', () => ({ + selectCardExperimentalSwitch: jest.fn(), + selectCardSupportedCountries: jest.fn(), + selectDisplayCardButtonFeatureFlag: jest.fn(), +})); + import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { isEthAccount } from '../../../Multichain/utils'; +import { + selectCardExperimentalSwitch, + selectCardSupportedCountries, + selectDisplayCardButtonFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; const mockSelectSelectedInternalAccountByScope = selectSelectedInternalAccountByScope as jest.MockedFunction< @@ -42,6 +58,21 @@ const mockIsEthAccount = isEthAccount as jest.MockedFunction< typeof isEthAccount >; +const mockSelectCardExperimentalSwitch = + selectCardExperimentalSwitch as jest.MockedFunction< + typeof selectCardExperimentalSwitch + >; + +const mockSelectCardSupportedCountries = + selectCardSupportedCountries as jest.MockedFunction< + typeof selectCardSupportedCountries + >; + +const mockSelectDisplayCardButtonFeatureFlag = + selectDisplayCardButtonFeatureFlag as jest.MockedFunction< + typeof selectDisplayCardButtonFeatureFlag + >; + const CARDHOLDER_ACCOUNTS_MOCK: string[] = [ '0x1234567890123456789012345678901234567890', '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', @@ -70,6 +101,9 @@ const CARD_STATE_MOCK: CardSliceState = { }, isLoaded: true, hasViewedCardButton: true, + alwaysShowCardButton: false, + geoLocation: 'US', + isAuthenticated: false, }; const EMPTY_CARD_STATE_MOCK: CardSliceState = { @@ -78,6 +112,9 @@ const EMPTY_CARD_STATE_MOCK: CardSliceState = { lastFetchedByAddress: {}, isLoaded: false, hasViewedCardButton: false, + alwaysShowCardButton: false, + geoLocation: 'UNKNOWN', + isAuthenticated: false, }; // Mock account object that matches the expected structure @@ -134,6 +171,35 @@ describe('Card Selectors', () => { }); }); + describe('selectCardGeoLocation', () => { + it('returns UNKNOWN by default from initial state', () => { + const mockRootState = { card: initialState } as unknown as RootState; + expect(selectCardGeoLocation(mockRootState)).toBe('UNKNOWN'); + }); + + it('returns the geolocation from the card state', () => { + const mockRootState = { + card: CARD_STATE_MOCK, + } as unknown as RootState; + expect(selectCardGeoLocation(mockRootState)).toBe('US'); + }); + + it('returns different geolocation values correctly', () => { + const geoLocations = ['US', 'GB', 'CA', 'DE', 'FR', 'UNKNOWN']; + + geoLocations.forEach((geoLocation) => { + const stateWithGeoLocation: CardSliceState = { + ...initialState, + geoLocation, + }; + const mockRootState = { + card: stateWithGeoLocation, + } as unknown as RootState; + expect(selectCardGeoLocation(mockRootState)).toBe(geoLocation); + }); + }); + }); + describe('selectIsCardholder', () => { beforeEach(() => { jest.clearAllMocks(); @@ -220,26 +286,51 @@ describe('Card Selectors', () => { describe('Card Reducer', () => { describe('extraReducers', () => { describe('loadCardholderAccounts', () => { - it('should set cardholder accounts and update state when fulfilled', () => { - const mockAccounts = ['0x123', '0x456']; + it('should set cardholder accounts, geolocation, and update state when fulfilled', () => { + const mockPayload = { + cardholderAddresses: ['0x123', '0x456'], + geoLocation: 'US', + }; + const action = { + type: loadCardholderAccounts.fulfilled.type, + payload: mockPayload, + }; + const state = cardReducer(initialState, action); + + expect(state.cardholderAccounts).toEqual(['0x123', '0x456']); + expect(state.geoLocation).toBe('US'); + expect(state.isLoaded).toBe(true); + }); + + it('should handle empty cardholderAddresses in payload when fulfilled', () => { + const mockPayload = { + cardholderAddresses: [], + geoLocation: 'GB', + }; const action = { type: loadCardholderAccounts.fulfilled.type, - payload: mockAccounts, + payload: mockPayload, }; const state = cardReducer(initialState, action); - expect(state.cardholderAccounts).toEqual(mockAccounts); + expect(state.cardholderAccounts).toEqual([]); + expect(state.geoLocation).toBe('GB'); expect(state.isLoaded).toBe(true); }); - it('should handle empty payload when fulfilled', () => { + it('should handle null cardholderAddresses and fallback to UNKNOWN geolocation', () => { + const mockPayload = { + cardholderAddresses: null, + geoLocation: null, + }; const action = { type: loadCardholderAccounts.fulfilled.type, - payload: null, + payload: mockPayload, }; const state = cardReducer(initialState, action); expect(state.cardholderAccounts).toEqual([]); + expect(state.geoLocation).toBe('UNKNOWN'); expect(state.isLoaded).toBe(true); }); @@ -255,6 +346,25 @@ describe('Card Reducer', () => { expect(state.isLoaded).toBe(true); expect(state.cardholderAccounts).toEqual([]); // Should remain empty on error + expect(state.geoLocation).toBe('UNKNOWN'); // Should remain UNKNOWN on error + }); + + it('should handle different geolocation values', () => { + const geoLocations = ['US', 'GB', 'CA', 'DE', 'FR', 'UNKNOWN']; + + geoLocations.forEach((geoLocation) => { + const mockPayload = { + cardholderAddresses: ['0x123'], + geoLocation, + }; + const action = { + type: loadCardholderAccounts.fulfilled.type, + payload: mockPayload, + }; + const state = cardReducer(initialState, action); + + expect(state.geoLocation).toBe(geoLocation); + }); }); }); }); @@ -271,11 +381,16 @@ describe('Card Reducer', () => { }, isLoaded: true, hasViewedCardButton: true, + alwaysShowCardButton: true, + geoLocation: 'US', + isAuthenticated: false, }; const state = cardReducer(currentState, resetCardState()); expect(state).toEqual(initialState); + expect(state.geoLocation).toBe('UNKNOWN'); + expect(state.alwaysShowCardButton).toBe(false); }); describe('setHasViewedCardButton', () => { @@ -753,3 +868,305 @@ describe('Card Caching Functionality', () => { }); }); }); + +describe('Card Button Display Selectors', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('selectAlwaysShowCardButton', () => { + it('should return false when experimental switch is disabled', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(false); + + const stateWithAlwaysShow: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + }; + + const mockRootState = { + card: stateWithAlwaysShow, + } as unknown as RootState; + + expect(selectAlwaysShowCardButton(mockRootState)).toBe(false); + }); + + it('should return stored value when experimental switch is enabled', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(true); + + const stateWithAlwaysShow: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + }; + + const mockRootState = { + card: stateWithAlwaysShow, + } as unknown as RootState; + + expect(selectAlwaysShowCardButton(mockRootState)).toBe(true); + }); + + it('should return false when experimental switch is enabled but stored value is false', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(true); + + const stateWithoutAlwaysShow: CardSliceState = { + ...initialState, + alwaysShowCardButton: false, + }; + + const mockRootState = { + card: stateWithoutAlwaysShow, + } as unknown as RootState; + + expect(selectAlwaysShowCardButton(mockRootState)).toBe(false); + }); + + it('should return false by default with initial state', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(false); + + const mockRootState = { + card: initialState, + } as unknown as RootState; + + expect(selectAlwaysShowCardButton(mockRootState)).toBe(false); + }); + }); + + describe('setAlwaysShowCardButton', () => { + it('should set alwaysShowCardButton to true', () => { + const state = cardReducer(initialState, setAlwaysShowCardButton(true)); + expect(state.alwaysShowCardButton).toBe(true); + }); + + it('should set alwaysShowCardButton to false', () => { + const currentState: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + }; + + const state = cardReducer(currentState, setAlwaysShowCardButton(false)); + expect(state.alwaysShowCardButton).toBe(false); + }); + + it('should not affect other state properties', () => { + const state = cardReducer(initialState, setAlwaysShowCardButton(true)); + expect(state.cardholderAccounts).toEqual(initialState.cardholderAccounts); + expect(state.geoLocation).toEqual(initialState.geoLocation); + expect(state.isLoaded).toEqual(initialState.isLoaded); + }); + }); + + describe('selectDisplayCardButton', () => { + const mockAccountAddress = '0x1234567890123456789012345678901234567890'; + const mockAccount = { + address: mockAccountAddress.toLowerCase(), + id: 'mock-id', + metadata: { + name: 'Mock Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, + options: {}, + methods: [], + type: 'eip155:eoa' as const, + scopes: ['eip155:59144' as const], + }; + + beforeEach(() => { + // Reset all mocks to default false/empty values + mockSelectCardExperimentalSwitch.mockReturnValue(false); + mockSelectCardSupportedCountries.mockReturnValue({}); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(false); + mockSelectSelectedInternalAccountByScope.mockReturnValue(() => undefined); + mockIsEthAccount.mockReturnValue(false); + }); + + it('should return true when alwaysShowCardButton is true', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(true); + + const stateWithAlwaysShow: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + geoLocation: 'XX', // Unsupported country + cardholderAccounts: [], // Not a cardholder + }; + + const mockRootState = { + card: stateWithAlwaysShow, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(true); + }); + + it('should return true when user is a cardholder', () => { + const stateWithCardholder: CardSliceState = { + ...initialState, + cardholderAccounts: [mockAccountAddress.toLowerCase()], + alwaysShowCardButton: false, + geoLocation: 'XX', // Unsupported country + }; + + mockSelectSelectedInternalAccountByScope.mockReturnValue( + () => mockAccount, + ); + mockIsEthAccount.mockReturnValue(true); + mockSelectCardExperimentalSwitch.mockReturnValue(false); + + const mockRootState = { + card: stateWithCardholder, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(true); + }); + + it('should return true when in supported country with feature flag enabled', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(true); + + const stateWithGeoLocation: CardSliceState = { + ...initialState, + geoLocation: 'US', + cardholderAccounts: [], + alwaysShowCardButton: false, + }; + + const mockRootState = { + card: stateWithGeoLocation, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(true); + }); + + it('should return false when in supported country but feature flag is disabled', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(false); + + const stateWithGeoLocation: CardSliceState = { + ...initialState, + geoLocation: 'US', + cardholderAccounts: [], + alwaysShowCardButton: false, + }; + + const mockRootState = { + card: stateWithGeoLocation, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(false); + }); + + it('should return false when feature flag is enabled but country is not supported', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(true); + + const stateWithUnsupportedGeoLocation: CardSliceState = { + ...initialState, + geoLocation: 'CN', // Not in supported countries + cardholderAccounts: [], + alwaysShowCardButton: false, + }; + + const mockRootState = { + card: stateWithUnsupportedGeoLocation, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(false); + }); + + it('should return false when no conditions are met', () => { + const mockRootState = { + card: initialState, + } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(false); + }); + + it('should handle multiple supported countries', () => { + mockSelectCardSupportedCountries.mockReturnValue({ + US: true, + GB: true, + CA: true, + DE: false, // Explicitly false + }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(true); + + // Test US + let state: CardSliceState = { + ...initialState, + geoLocation: 'US', + cardholderAccounts: [], + alwaysShowCardButton: false, + }; + let mockRootState = { card: state } as unknown as RootState; + expect(selectDisplayCardButton(mockRootState)).toBe(true); + + // Test GB + state = { ...state, geoLocation: 'GB' }; + mockRootState = { card: state } as unknown as RootState; + expect(selectDisplayCardButton(mockRootState)).toBe(true); + + // Test CA + state = { ...state, geoLocation: 'CA' }; + mockRootState = { card: state } as unknown as RootState; + expect(selectDisplayCardButton(mockRootState)).toBe(true); + + // Test DE (explicitly false) + state = { ...state, geoLocation: 'DE' }; + mockRootState = { card: state } as unknown as RootState; + expect(selectDisplayCardButton(mockRootState)).toBe(false); + }); + + it('should prioritize alwaysShowCardButton over other conditions', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(true); + mockSelectCardSupportedCountries.mockReturnValue({}); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(false); + + const state: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + geoLocation: 'XX', + cardholderAccounts: [], + }; + + const mockRootState = { card: state } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(true); + }); + + it('should handle UNKNOWN geolocation gracefully', () => { + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(true); + + const state: CardSliceState = { + ...initialState, + geoLocation: 'UNKNOWN', + cardholderAccounts: [], + alwaysShowCardButton: false, + }; + + const mockRootState = { card: state } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(false); + }); + + it('should return true when all three conditions are true', () => { + mockSelectCardExperimentalSwitch.mockReturnValue(true); + mockSelectCardSupportedCountries.mockReturnValue({ US: true }); + mockSelectDisplayCardButtonFeatureFlag.mockReturnValue(true); + mockSelectSelectedInternalAccountByScope.mockReturnValue( + () => mockAccount, + ); + mockIsEthAccount.mockReturnValue(true); + + const state: CardSliceState = { + ...initialState, + alwaysShowCardButton: true, + geoLocation: 'US', + cardholderAccounts: [mockAccountAddress.toLowerCase()], + }; + + const mockRootState = { card: state } as unknown as RootState; + + expect(selectDisplayCardButton(mockRootState)).toBe(true); + }); + }); +}); diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts index e1c30ab1485f..e4b92785ea3e 100644 --- a/app/core/redux/slices/card/index.ts +++ b/app/core/redux/slices/card/index.ts @@ -6,6 +6,11 @@ import Logger from '../../../../util/Logger'; import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { isEthAccount } from '../../../Multichain/utils'; import { CardTokenAllowance } from '../../../../components/UI/Card/types'; +import { + selectCardExperimentalSwitch, + selectCardSupportedCountries, + selectDisplayCardButtonFeatureFlag, +} from '../../../../selectors/featureFlagController/card'; export interface CardSliceState { cardholderAccounts: string[]; @@ -13,6 +18,9 @@ export interface CardSliceState { lastFetchedByAddress: Record; hasViewedCardButton: boolean; isLoaded: boolean; + alwaysShowCardButton: boolean; + geoLocation: string; + isAuthenticated: boolean; } export const initialState: CardSliceState = { @@ -21,6 +29,9 @@ export const initialState: CardSliceState = { lastFetchedByAddress: {}, hasViewedCardButton: false, isLoaded: false, + alwaysShowCardButton: false, + geoLocation: 'UNKNOWN', + isAuthenticated: false, }; // Async thunk for loading cardholder accounts @@ -59,11 +70,18 @@ const slice = createSlice({ state.lastFetchedByAddress[action.payload.address.toLowerCase()] = action.payload.lastFetched; }, + setAlwaysShowCardButton: (state, action: PayloadAction) => { + state.alwaysShowCardButton = action.payload; + }, + setIsAuthenticatedCard: (state, action: PayloadAction) => { + state.isAuthenticated = action.payload; + }, }, extraReducers: (builder) => { builder .addCase(loadCardholderAccounts.fulfilled, (state, action) => { - state.cardholderAccounts = action.payload ?? []; + state.cardholderAccounts = action.payload.cardholderAddresses ?? []; + state.geoLocation = action.payload.geoLocation ?? 'UNKNOWN'; state.isLoaded = true; }) .addCase(loadCardholderAccounts.rejected, (state, action) => { @@ -114,6 +132,27 @@ export const selectIsCardCacheValid = (address?: string) => return lastFetchedDate > fiveMinutesAgo; }); +export const selectAlwaysShowCardButton = createSelector( + selectCardState, + selectCardExperimentalSwitch, + (card, cardExperimentalSwitchFlagEnabled) => { + // Get the stored value of alwaysShowCardButton from the card state. + // That's stored in a persistent storage. + // If the feature flag is disabled, we return false. + // Otherwise, we return the stored value. + const alwaysShowCardButtonStoredValue = card.alwaysShowCardButton; + + return cardExperimentalSwitchFlagEnabled + ? alwaysShowCardButtonStoredValue + : false; + }, +); + +export const selectCardGeoLocation = createSelector( + selectCardState, + (card) => card.geoLocation, +); + export const selectIsCardholder = createSelector( selectCardholderAccounts, selectedAccount, @@ -133,10 +172,47 @@ export const selectHasViewedCardButton = createSelector( (card) => card.hasViewedCardButton, ); +export const selectIsAuthenticatedCard = createSelector( + selectCardState, + (card) => card.isAuthenticated, +); + +export const selectDisplayCardButton = createSelector( + selectIsCardholder, + selectAlwaysShowCardButton, + selectCardGeoLocation, + selectCardSupportedCountries, + selectDisplayCardButtonFeatureFlag, + selectIsAuthenticatedCard, + ( + isCardholder, + alwaysShowCardButton, + geoLocation, + cardSupportedCountries, + displayCardButtonFeatureFlag, + isAuthenticated, + ) => { + if ( + alwaysShowCardButton || + isCardholder || + isAuthenticated || + ((cardSupportedCountries as Record)?.[geoLocation] === + true && + displayCardButtonFeatureFlag) + ) { + return true; + } + + return false; + }, +); + // Actions export const { resetCardState, + setAlwaysShowCardButton, + setHasViewedCardButton, + setIsAuthenticatedCard, setCardPriorityToken, setCardPriorityTokenLastFetched, - setHasViewedCardButton, } = actions; diff --git a/app/selectors/featureFlagController/card/index.test.ts b/app/selectors/featureFlagController/card/index.test.ts index 166bc6805f72..afd839488899 100644 --- a/app/selectors/featureFlagController/card/index.test.ts +++ b/app/selectors/featureFlagController/card/index.test.ts @@ -1,20 +1,31 @@ import { CardFeatureFlag, + CardSupportedCountries, SupportedChain, SupportedToken, selectCardFeatureFlag, + selectCardSupportedCountries, + selectDisplayCardButtonFeatureFlag, + selectCardExperimentalSwitch, } from '.'; import mockedEngine from '../../../core/__mocks__/MockedEngine'; import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks'; +import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; jest.mock('../../../core/Engine', () => ({ init: () => mockedEngine.init(), })); +jest.mock('../../../util/remoteFeatureFlag', () => ({ + validatedVersionGatedFeatureFlag: jest.fn(), +})); + const originalEnv = process.env; + beforeEach(() => { jest.resetModules(); process.env = { ...originalEnv }; + jest.clearAllMocks(); }); afterEach(() => { @@ -94,20 +105,29 @@ const mockedStateWithPartialCardFeatureFlag = { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; -describe('Card Feature Flag Selector', () => { - it('returns null when feature flag state is empty', () => { - const result = selectCardFeatureFlag(mockedEmptyFlagsState); +describe('selectCardFeatureFlag', () => { + it('returns default card feature flag when feature flag state is empty', () => { + const result = selectCardFeatureFlag( + mockedEmptyFlagsState, + ) as CardFeatureFlag; - expect(result).toEqual(null); + expect(result).toBeDefined(); + expect(result.constants).toBeDefined(); + expect(result.chains).toBeDefined(); + expect(result.isBaanxLoginEnabled).toBe(false); }); - it('returns null when RemoteFeatureFlagController state is undefined', () => { - const result = selectCardFeatureFlag(mockedUndefinedFlagsState); + it('returns default card feature flag when RemoteFeatureFlagController state is undefined', () => { + const result = selectCardFeatureFlag( + mockedUndefinedFlagsState, + ) as CardFeatureFlag; - expect(result).toEqual(null); + expect(result).toBeDefined(); + expect(result.constants).toBeDefined(); + expect(result.chains).toBeDefined(); }); - it('returns null when cardFeature is null', () => { + it('returns default card feature flag when cardFeature is null', () => { const stateWithNullCardFlag = { engine: { backgroundState: { @@ -121,9 +141,13 @@ describe('Card Feature Flag Selector', () => { }, }; - const result = selectCardFeatureFlag(stateWithNullCardFlag); + const result = selectCardFeatureFlag( + stateWithNullCardFlag, + ) as CardFeatureFlag; - expect(result).toBeNull(); + expect(result).toBeDefined(); + expect(result.constants).toBeDefined(); + expect(result.chains).toBeDefined(); }); it('returns card feature flag when properly configured', () => { @@ -219,11 +243,13 @@ describe('Card Feature Flag Selector', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; - const result = selectCardFeatureFlag(stateWithMultipleTokens); + const result = selectCardFeatureFlag( + stateWithMultipleTokens, + ) as CardFeatureFlag; - expect(result?.chains?.['1']?.tokens).toHaveLength(2); - expect(result?.chains?.['1']?.tokens?.[0]).toEqual(mockedSupportedToken); - expect(result?.chains?.['1']?.tokens?.[1]).toEqual({ + expect(result.chains?.['1']?.tokens).toHaveLength(2); + expect(result.chains?.['1']?.tokens?.[0]).toEqual(mockedSupportedToken); + expect(result.chains?.['1']?.tokens?.[1]).toEqual({ address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', decimals: 6, enabled: false, @@ -251,12 +277,312 @@ describe('Card Feature Flag Selector', () => { }, }, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardFeatureFlag( + stateWithDisabledChain, + ) as CardFeatureFlag; + + expect(result.chains?.['1']?.enabled).toBe(false); + expect(result.chains?.['1']?.tokens).toBeNull(); + }); +}); + +describe('selectCardSupportedCountries', () => { + it('returns default supported countries when feature flag state is empty', () => { + const result = selectCardSupportedCountries( + mockedEmptyFlagsState, + ) as CardSupportedCountries; + + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + expect(result.GB).toBe(true); + expect(result.US).toBeUndefined(); + }); + + it('returns default supported countries when RemoteFeatureFlagController state is undefined', () => { + const result = selectCardSupportedCountries( + mockedUndefinedFlagsState, + ) as CardSupportedCountries; + + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + }); + + it('returns custom supported countries when defined in remote flags', () => { + const customCountries: CardSupportedCountries = { + US: true, + CA: true, + GB: false, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = selectCardFeatureFlag(stateWithDisabledChain as any); + const stateWithCustomCountries = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardSupportedCountries: customCountries, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardSupportedCountries( + stateWithCustomCountries, + ) as CardSupportedCountries; + + expect(result).toEqual(customCountries); + expect(result.US).toBe(true); + expect(result.CA).toBe(true); + expect(result.GB).toBe(false); + }); + + it('handles null cardSupportedCountries by returning default', () => { + const stateWithNullCountries = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardSupportedCountries: null, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardSupportedCountries(stateWithNullCountries); + + expect(result).toBeDefined(); + expect(typeof result).toBe('object'); + }); +}); + +describe('selectDisplayCardButtonFeatureFlag', () => { + const mockedValidatedVersionGatedFeatureFlag = + validatedVersionGatedFeatureFlag as jest.MockedFunction< + typeof validatedVersionGatedFeatureFlag + >; + + it('returns false when feature flag state is empty', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectDisplayCardButtonFeatureFlag(mockedEmptyFlagsState); + + expect(result).toBe(false); + }); + + it('returns false when RemoteFeatureFlagController state is undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const result = selectDisplayCardButtonFeatureFlag( + mockedUndefinedFlagsState, + ); + + expect(result).toBe(false); + }); + + it('returns true when feature flag is enabled and version requirement is met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(true); + + const stateWithDisplayCardButton = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + displayCardButton: { + enabled: true, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectDisplayCardButtonFeatureFlag( + stateWithDisplayCardButton, + ); + + expect(result).toBe(true); + expect(mockedValidatedVersionGatedFeatureFlag).toHaveBeenCalledWith({ + enabled: true, + minimumVersion: '7.0.0', + }); + }); + + it('returns false when feature flag is disabled', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithDisabledFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + displayCardButton: { + enabled: false, + minimumVersion: '7.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectDisplayCardButtonFeatureFlag(stateWithDisabledFlag); + + expect(result).toBe(false); + }); + + it('returns false when version requirement is not met', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(false); + + const stateWithVersionGate = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + displayCardButton: { + enabled: true, + minimumVersion: '99.0.0', + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectDisplayCardButtonFeatureFlag(stateWithVersionGate); + + expect(result).toBe(false); + }); + + it('returns false when validatedVersionGatedFeatureFlag returns undefined', () => { + mockedValidatedVersionGatedFeatureFlag.mockReturnValue(undefined); + + const stateWithMalformedFlag = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + displayCardButton: { + enabled: 'true', // Invalid type + }, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectDisplayCardButtonFeatureFlag(stateWithMalformedFlag); + + expect(result).toBe(false); + }); +}); + +describe('selectCardExperimentalSwitch', () => { + it('returns false when feature flag state is empty', () => { + const result = selectCardExperimentalSwitch(mockedEmptyFlagsState); + + expect(result).toBe(false); + }); + + it('returns false when RemoteFeatureFlagController state is undefined', () => { + const result = selectCardExperimentalSwitch(mockedUndefinedFlagsState); + + expect(result).toBe(false); + }); + + it('returns true when cardExperimentalSwitch is enabled', () => { + const stateWithExperimentalSwitch = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardExperimentalSwitch: true, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardExperimentalSwitch(stateWithExperimentalSwitch); + + expect(result).toBe(true); + }); + + it('returns false when cardExperimentalSwitch is disabled', () => { + const stateWithDisabledSwitch = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardExperimentalSwitch: false, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardExperimentalSwitch(stateWithDisabledSwitch); + + expect(result).toBe(false); + }); + + it('returns false when cardExperimentalSwitch is null', () => { + const stateWithNullSwitch = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardExperimentalSwitch: null, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardExperimentalSwitch(stateWithNullSwitch); + + expect(result).toBe(false); + }); + + it('returns false when cardExperimentalSwitch is undefined', () => { + const stateWithUndefinedSwitch = { + engine: { + backgroundState: { + RemoteFeatureFlagController: { + remoteFeatureFlags: { + cardExperimentalSwitch: undefined, + }, + cacheTimestamp: 0, + }, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const result = selectCardExperimentalSwitch(stateWithUndefinedSwitch); - expect(result?.chains?.['1']?.enabled).toBe(false); - expect(result?.chains?.['1']?.tokens).toBeNull(); + expect(result).toBe(false); }); }); diff --git a/app/selectors/featureFlagController/card/index.ts b/app/selectors/featureFlagController/card/index.ts index 4c8fcfdac329..977a2b7e0cd1 100644 --- a/app/selectors/featureFlagController/card/index.ts +++ b/app/selectors/featureFlagController/card/index.ts @@ -1,5 +1,159 @@ import { createSelector } from 'reselect'; import { selectRemoteFeatureFlags } from '..'; +import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; + +const defaultCardFeatureFlag: CardFeatureFlag = { + chains: { + 'eip155:59144': { + balanceScannerAddress: '0xed9f04f2da1b42ae558d5e688fe2ef7080931c9a', + enabled: true, + foxConnectAddresses: { + global: '0x9dd23A4a0845f10d65D293776B792af1131c7B30', + us: '0xA90b298d05C2667dDC64e2A4e17111357c215dD2', + }, + tokens: [ + { + address: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', + decimals: 6, + enabled: true, + name: 'USD Coin', + symbol: 'USDC', + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + decimals: 6, + enabled: true, + name: 'Tether USD', + symbol: 'USDT', + }, + { + address: '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f', + decimals: 18, + enabled: true, + name: 'Wrapped Ether', + symbol: 'WETH', + }, + { + address: '0x3ff47c5Bf409C86533FE1f4907524d304062428D', + decimals: 18, + enabled: true, + name: 'EURe', + symbol: 'EURe', + }, + { + address: '0x3Bce82cf1A2bc357F956dd494713Fe11DC54780f', + decimals: 18, + enabled: true, + name: 'GBPe', + symbol: 'GBPe', + }, + { + address: '0x374D7860c4f2f604De0191298dD393703Cce84f3', + decimals: 6, + enabled: true, + name: 'Aave USDC', + symbol: 'aUSDC', + }, + { + address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + decimals: 6, + enabled: true, + name: 'MetaMask USD', + symbol: 'mUSD', + }, + ], + }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + enabled: true, + tokens: [ + { + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + enabled: true, + name: 'USDC', + symbol: 'USDC', + }, + { + address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', + decimals: 6, + enabled: true, + name: 'USDT', + symbol: 'USDT', + }, + ], + }, + }, + constants: { + accountsApiUrl: 'https://accounts.api.cx.metamask.io', + onRampApiUrl: 'https://on-ramp.uat-api.cx.metamask.io', + }, + isBaanxLoginEnabled: false, +}; + +const defaultCardSupportedCountries: CardSupportedCountries = { + GG: true, + DE: true, + NO: true, + 'CA-QC': true, + GI: true, + AD: true, + BE: true, + FI: true, + 'CA-NB': true, + SV: true, + IM: true, + 'CA-MB': true, + 'CA-PE': true, + PT: true, + UY: true, + BG: true, + CH: true, + DK: true, + MT: true, + 'CA-SK': true, + LU: true, + HR: true, + IS: true, + 'CA-YT': true, + GR: true, + IT: true, + MX: true, + CO: true, + FR: true, + GT: true, + HU: true, + 'CA-NL': true, + ES: true, + 'CA-ON': true, + 'CA-BC': true, + BR: true, + 'CA-AB': true, + AR: true, + PA: true, + SE: true, + AT: true, + 'CA-NS': true, + 'CA-NT': true, + CY: true, + 'CA-NU': true, + SI: true, + UK: true, + SK: true, + GB: true, + JE: true, + IE: true, + PL: true, + LI: true, + RO: true, + NL: true, +}; + +export type CardSupportedCountries = Record; + +export interface DisplayCardButtonFeatureFlag { + enabled: boolean; + minimumVersion: string; +} export interface CardFeatureFlag { isBaanxLoginEnabled?: boolean; @@ -25,10 +179,33 @@ export interface SupportedToken { symbol?: string | null; } +export const selectCardSupportedCountries = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => + remoteFeatureFlags?.cardSupportedCountries ?? + (defaultCardSupportedCountries as CardSupportedCountries), +); + +export const selectDisplayCardButtonFeatureFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.displayCardButton as unknown as DisplayCardButtonFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); + +export const selectCardExperimentalSwitch = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => remoteFeatureFlags?.cardExperimentalSwitch ?? false, +); + export const selectCardFeatureFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { const cardFeatureFlag = remoteFeatureFlags?.cardFeature; - return (cardFeatureFlag ?? null) as CardFeatureFlag | null; + + return cardFeatureFlag ?? defaultCardFeatureFlag; }, ); diff --git a/locales/languages/en.json b/locales/languages/en.json index 61029d871030..a9c5f7f530d0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3970,7 +3970,9 @@ "wallet_connect_dapps_cta": "View sessions", "network_not_supported": "Current network not supported", "select_provider": "Select your preferred provider", - "switch_network": "Please switch to mainnet or sepolia" + "switch_network": "Please switch to mainnet or sepolia", + "card_title": "Always show MetaMask Card button", + "card_desc": "MetaMask Card is only available to residents of select countries" }, "walletconnect_sessions": { "no_active_sessions": "You have no active sessions",