diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 70f7d78f6..9d7cee4dc 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -25,7 +25,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1170.0) + aws-partitions (1.1171.0) aws-sdk-core (3.233.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -46,7 +46,7 @@ GEM babosa (1.0.4) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.3.0) + bigdecimal (3.3.1) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 3f9ec842e..2833a31a3 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1960,7 +1960,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNLocalize (3.5.2): + - RNLocalize (3.5.3): - DoubleConversion - glog - hermes-engine @@ -2558,7 +2558,7 @@ SPEC CHECKSUMS: RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b RNGestureHandler: a63b531307e5b2e6ea21d053a1a7ad4cf9695c57 RNKeychain: 471ceef8c13f15a5534c3cd2674dbbd9d0680e52 - RNLocalize: a2c93da4b4afae4630d4b3be79320c11c4342d1f + RNLocalize: 7683e450496a5aea9a2dab3745bfefa7341d3f5e RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73 RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8 RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0 diff --git a/app/jest.setup.js b/app/jest.setup.js index 36d1c068b..d8c1bcc44 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -682,9 +682,25 @@ jest.mock('./src/utils/notifications/notificationService', () => require('./tests/__setup__/notificationServiceMock.js'), ); +// Create a global mock navigation ref that can be used by all tests +global.mockNavigationRef = { + isReady: jest.fn(() => true), + navigate: jest.fn(), + goBack: jest.fn(), + canGoBack: jest.fn(() => true), + dispatch: jest.fn(), + addListener: jest.fn(() => jest.fn()), + removeListener: jest.fn(), + getCurrentRoute: jest.fn(() => ({ name: 'Home' })), + getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })), + resetRoot: jest.fn(), + getRootState: jest.fn(), +}; + // Mock React Navigation jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); + const React = jest.requireActual('react'); return { ...actualNav, useNavigation: jest.fn(() => ({ @@ -692,18 +708,27 @@ jest.mock('@react-navigation/native', () => { goBack: jest.fn(), canGoBack: jest.fn(() => true), dispatch: jest.fn(), + getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })), })), - createNavigationContainerRef: jest.fn(() => ({ - current: null, - getCurrentRoute: jest.fn(), - })), - createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })), + createNavigationContainerRef: jest.fn(() => global.mockNavigationRef), + createStaticNavigation: jest.fn(() => { + const MockNavigator = React.forwardRef((props, _ref) => props.children); + MockNavigator.displayName = 'MockNavigator'; + return MockNavigator; + }), }; }); jest.mock('@react-navigation/native-stack', () => ({ - createNativeStackNavigator: jest.fn(() => ({ - displayName: 'MockStackNavigator', - })), + createNativeStackNavigator: jest.fn(config => config), createNavigatorFactory: jest.fn(), })); + +// Mock the navigation module to provide navigationRef to all components/hooks +// This mock will be used by default, but individual tests can override it +jest.mock('./src/navigation', () => ({ + __esModule: true, + navigationRef: global.mockNavigationRef, + navigationScreens: {}, + default: () => null, +})); diff --git a/app/src/consts/recoveryPrompts.ts b/app/src/consts/recoveryPrompts.ts new file mode 100644 index 000000000..d39936a56 --- /dev/null +++ b/app/src/consts/recoveryPrompts.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Capture and scanning flows that should never be interrupted by a modal. We + * keep this block list both for documentation and to use in tests. + */ +export type RecoveryPromptAllowedRoute = + (typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number]; + +/** + * Screens where we intentionally show the recovery reminder. This allow list is + * intentionally short so that new product surfaces do not accidentally inherit + * the prompt without design review. + */ +export const CRITICAL_RECOVERY_PROMPT_ROUTES = [ + 'DocumentCamera', + 'DocumentCameraTrouble', + 'DocumentNFCMethodSelection', + 'DocumentNFCScan', + 'DocumentNFCTrouble', + 'QRCodeViewFinder', + 'QRCodeTrouble', +] as const; + +export const RECOVERY_PROMPT_ALLOWED_ROUTES = [ + 'Home', + 'ProofHistory', + 'ProofHistoryDetail', + 'ManageDocuments', + 'Settings', +] as const; diff --git a/app/src/hooks/useModal.ts b/app/src/hooks/useModal.ts index 2e29010ca..89b74e6ac 100644 --- a/app/src/hooks/useModal.ts +++ b/app/src/hooks/useModal.ts @@ -3,10 +3,8 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { useCallback, useRef, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { RootStackParamList } from '@/navigation'; +import { navigationRef } from '@/navigation'; import type { ModalParams } from '@/screens/app/ModalScreen'; import { getModalCallbacks, @@ -16,23 +14,28 @@ import { export const useModal = (params: ModalParams) => { const [visible, setVisible] = useState(false); - const navigation = - useNavigation>(); const callbackIdRef = useRef(); const showModal = useCallback(() => { + if (!navigationRef.isReady()) { + // Navigation not ready yet; avoid throwing and simply skip showing + return; + } setVisible(true); const { onButtonPress, onModalDismiss, ...rest } = params; const id = registerModalCallbacks({ onButtonPress, onModalDismiss }); callbackIdRef.current = id; - navigation.navigate('Modal', { ...rest, callbackId: id }); - }, [params, navigation]); + navigationRef.navigate('Modal', { ...rest, callbackId: id }); + }, [params]); const dismissModal = useCallback(() => { setVisible(false); - const routes = navigation.getState()?.routes; + if (!navigationRef.isReady()) { + return; + } + const routes = navigationRef.getState()?.routes; if (routes?.at(routes.length - 1)?.name === 'Modal') { - navigation.goBack(); + navigationRef.goBack(); } if (callbackIdRef.current !== undefined) { const callbacks = getModalCallbacks(callbackIdRef.current); @@ -47,7 +50,7 @@ export const useModal = (params: ModalParams) => { unregisterModalCallbacks(callbackIdRef.current); callbackIdRef.current = undefined; } - }, [navigation]); + }, []); return { showModal, diff --git a/app/src/hooks/useRecoveryPrompts.ts b/app/src/hooks/useRecoveryPrompts.ts index 5d9aaf538..f7f327ba4 100644 --- a/app/src/hooks/useRecoveryPrompts.ts +++ b/app/src/hooks/useRecoveryPrompts.ts @@ -2,15 +2,27 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { AppStateStatus } from 'react-native'; +import { AppState } from 'react-native'; +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; import { useModal } from '@/hooks/useModal'; import { navigationRef } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; import { useSettingStore } from '@/stores/settingStore'; -// TODO: need to debug and test the logic. it pops up too often. -export default function useRecoveryPrompts() { +const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES; + +type UseRecoveryPromptsOptions = { + allowedRoutes?: readonly string[]; + disallowedRoutes?: readonly string[]; +}; + +export default function useRecoveryPrompts({ + allowedRoutes = DEFAULT_ALLOWED_ROUTES, + disallowedRoutes, +}: UseRecoveryPromptsOptions = {}) { const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } = useSettingStore(); const { getAllDocuments } = usePassport(); @@ -29,37 +41,115 @@ export default function useRecoveryPrompts() { onModalDismiss: () => {}, } as const); - useEffect(() => { - async function maybePrompt() { - if (!navigationRef.isReady()) { + const lastPromptCount = useRef(null); + const appStateStatus = useRef( + (AppState.currentState as AppStateStatus | null) ?? 'active', + ); + const allowedRouteSet = useMemo( + () => new Set(allowedRoutes), + [allowedRoutes], + ); + const disallowedRouteSet = useMemo( + () => (disallowedRoutes ? new Set(disallowedRoutes) : null), + [disallowedRoutes], + ); + + const isRouteEligible = useCallback( + (routeName: string | undefined): routeName is string => { + if (!routeName) { + return false; + } + if (!allowedRouteSet.has(routeName)) { + return false; + } + if (disallowedRouteSet?.has(routeName)) { + return false; + } + return true; + }, + [allowedRouteSet, disallowedRouteSet], + ); + + const maybePrompt = useCallback(async () => { + if (!navigationRef.isReady()) { + return; + } + if (appStateStatus.current !== 'active') { + return; + } + const currentRouteName = navigationRef.getCurrentRoute?.()?.name; + if (!isRouteEligible(currentRouteName)) { + return; + } + if (cloudBackupEnabled || hasViewedRecoveryPhrase) { + return; + } + try { + const docs = await getAllDocuments(); + if (Object.keys(docs).length === 0) { return; } - if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) { - try { - const docs = await getAllDocuments(); - if (Object.keys(docs).length === 0) { - return; - } - const shouldPrompt = - loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); - if (shouldPrompt) { - showModal(); - } - } catch { - // Silently fail to avoid breaking the hook - // If we can't get documents, we shouldn't show the prompt - return; - } + const shouldPrompt = + loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); + if (shouldPrompt && !visible && lastPromptCount.current !== loginCount) { + showModal(); + lastPromptCount.current = loginCount; } + } catch { + // Silently fail to avoid breaking the hook + // If we can't get documents, we shouldn't show the prompt + return; } - maybePrompt().catch(() => {}); }, [ - loginCount, cloudBackupEnabled, + getAllDocuments, hasViewedRecoveryPhrase, + isRouteEligible, + loginCount, showModal, - getAllDocuments, + visible, ]); + useEffect(() => { + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); + }, [maybePrompt]); + + useEffect(() => { + const handleAppStateChange = (nextState: AppStateStatus) => { + const previousState = appStateStatus.current; + appStateStatus.current = nextState; + if ( + (previousState === 'background' || previousState === 'inactive') && + nextState === 'active' + ) { + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); + } + }; + const subscription = AppState.addEventListener( + 'change', + handleAppStateChange, + ); + return () => { + subscription.remove(); + }; + }, [maybePrompt]); + + useEffect(() => { + const unsubscribe = navigationRef.addListener?.('state', () => { + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); + }); + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [maybePrompt]); + return { visible }; } diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index e35d0ce4e..c78eda462 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -16,6 +16,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { DefaultNavBar } from '@/components/NavBar'; +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; +import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; import AppLayout from '@/layouts/AppLayout'; import accountScreens from '@/navigation/account'; import appScreens from '@/navigation/app'; @@ -90,6 +92,7 @@ const { trackScreenView } = analytics(); const Navigation = createStaticNavigation(AppNavigation); const NavigationWithTracking = () => { + useRecoveryPrompts({ allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES }); const selfClient = useSelfClient(); const trackScreen = () => { const currentRoute = navigationRef.getCurrentRoute(); diff --git a/app/tests/src/hooks/useModal.test.ts b/app/tests/src/hooks/useModal.test.ts index ad2564ff6..48fd02729 100644 --- a/app/tests/src/hooks/useModal.test.ts +++ b/app/tests/src/hooks/useModal.test.ts @@ -2,32 +2,22 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { useNavigation } from '@react-navigation/native'; import { act, renderHook } from '@testing-library/react-native'; import { useModal } from '@/hooks/useModal'; import { getModalCallbacks } from '@/utils/modalCallbackRegistry'; -jest.mock('@react-navigation/native', () => ({ - useNavigation: jest.fn(), -})); - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockGetState = jest.fn(() => ({ - routes: [{ name: 'Home' }, { name: 'Modal' }], -})); - describe('useModal', () => { beforeEach(() => { - (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - goBack: mockGoBack, - getState: mockGetState, + // Reset all mocks including the global navigationRef + jest.clearAllMocks(); + + // Set up the navigation ref mock with proper methods + global.mockNavigationRef.isReady.mockReturnValue(true); + global.mockNavigationRef.getState.mockReturnValue({ + routes: [{ name: 'Home' }, { name: 'Modal' }], + index: 1, }); - mockNavigate.mockClear(); - mockGoBack.mockClear(); - mockGetState.mockClear(); }); it('should navigate to Modal with callbackId and handle dismissal', () => { @@ -45,8 +35,10 @@ describe('useModal', () => { act(() => result.current.showModal()); - expect(mockNavigate).toHaveBeenCalledTimes(1); - const params = mockNavigate.mock.calls[0][1]; + expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1); + const [screenName, params] = + global.mockNavigationRef.navigate.mock.calls[0]; + expect(screenName).toBe('Modal'); expect(params).toMatchObject({ titleText: 'Title', bodyText: 'Body', @@ -58,7 +50,7 @@ describe('useModal', () => { act(() => result.current.dismissModal()); - expect(mockGoBack).toHaveBeenCalled(); + expect(global.mockNavigationRef.goBack).toHaveBeenCalled(); expect(onModalDismiss).toHaveBeenCalled(); expect(getModalCallbacks(id)).toBeUndefined(); }); diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts index 912f96456..d30926491 100644 --- a/app/tests/src/hooks/useRecoveryPrompts.test.ts +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -4,29 +4,82 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { + CRITICAL_RECOVERY_PROMPT_ROUTES, + RECOVERY_PROMPT_ALLOWED_ROUTES, +} from '@/consts/recoveryPrompts'; import { useModal } from '@/hooks/useModal'; import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; import { usePassport } from '@/providers/passportDataProvider'; import { useSettingStore } from '@/stores/settingStore'; +const navigationStateListeners: Array<() => void> = []; +let isNavigationReady = true; +const appStateListeners: Array<(state: string) => void> = []; + jest.mock('@/hooks/useModal'); jest.mock('@/providers/passportDataProvider'); -jest.mock('@/navigation', () => ({ - navigationRef: { - isReady: jest.fn(() => true), - navigate: jest.fn(), - }, -})); +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + return { + ...actual, + AppState: { + currentState: 'active', + addEventListener: jest.fn( + (_: string, handler: (state: string) => void) => { + appStateListeners.push(handler); + return { + remove: () => { + const index = appStateListeners.indexOf(handler); + if (index >= 0) { + appStateListeners.splice(index, 1); + } + }, + }; + }, + ), + }, + }; +}); const showModal = jest.fn(); -(useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); const getAllDocuments = jest.fn(); (usePassport as jest.Mock).mockReturnValue({ getAllDocuments }); +const getAppState = () => + require('react-native').AppState as unknown as { + currentState: string; + addEventListener: jest.Mock; + }; + describe('useRecoveryPrompts', () => { beforeEach(() => { - showModal.mockClear(); + jest.clearAllMocks(); + navigationStateListeners.length = 0; + appStateListeners.length = 0; + isNavigationReady = true; + + // Setup the global navigation ref mock + global.mockNavigationRef.isReady.mockImplementation( + () => isNavigationReady, + ); + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' }); + global.mockNavigationRef.addListener.mockImplementation( + (_: string, callback: () => void) => { + navigationStateListeners.push(callback); + return () => { + const index = navigationStateListeners.indexOf(callback); + if (index >= 0) { + navigationStateListeners.splice(index, 1); + } + }; + }, + ); + + (useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); getAllDocuments.mockResolvedValue({ doc1: {} as any }); + const AppState = getAppState(); + AppState.currentState = 'active'; act(() => { useSettingStore.setState({ loginCount: 0, @@ -36,7 +89,7 @@ describe('useRecoveryPrompts', () => { }); }); - it('shows modal on first login', async () => { + it('shows modal on first login for eligible route', async () => { act(() => { useSettingStore.setState({ loginCount: 1 }); }); @@ -46,6 +99,88 @@ describe('useRecoveryPrompts', () => { }); }); + it('waits for navigation readiness before prompting', async () => { + isNavigationReady = false; + global.mockNavigationRef.isReady.mockImplementation( + () => isNavigationReady, + ); + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + + isNavigationReady = true; + navigationStateListeners.forEach(listener => listener()); + + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); + }); + + it.each([...CRITICAL_RECOVERY_PROMPT_ROUTES])( + 'does not show modal when route %s is disallowed', + async routeName => { + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ + name: routeName, + }); + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + const { unmount } = renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + unmount(); + }, + ); + + it('respects custom allow list overrides', async () => { + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => + useRecoveryPrompts({ allowedRoutes: ['Settings'], disallowedRoutes: [] }), + ); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + + showModal.mockClear(); + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ + name: 'Settings', + }); + + renderHook(() => + useRecoveryPrompts({ + allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES, + disallowedRoutes: [], + }), + ); + + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('prompts when returning from background on eligible route', async () => { + const AppState = getAppState(); + AppState.currentState = 'background'; + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + + appStateListeners.forEach(listener => listener('active')); + + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); + }); + it('does not show modal when login count is 4', async () => { act(() => { useSettingStore.setState({ loginCount: 4 }); @@ -76,9 +211,8 @@ describe('useRecoveryPrompts', () => { }); }); - it('does not show modal when navigation is not ready', async () => { - const navigationRef = require('@/navigation').navigationRef; - navigationRef.isReady.mockReturnValueOnce(false); + it('does not show modal if already visible', async () => { + (useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true }); act(() => { useSettingStore.setState({ loginCount: 1 }); }); @@ -125,6 +259,32 @@ describe('useRecoveryPrompts', () => { } }); + it('does not show modal again for same login count when state changes', async () => { + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).toHaveBeenCalledTimes(1); + }); + + showModal.mockClear(); + + act(() => { + useSettingStore.setState({ hasViewedRecoveryPhrase: true }); + }); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + + act(() => { + useSettingStore.setState({ hasViewedRecoveryPhrase: false }); + }); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + }); + it('returns correct visible state', () => { const { result } = renderHook(() => useRecoveryPrompts()); expect(result.current.visible).toBe(false); diff --git a/app/tests/src/navigation.test.ts b/app/tests/src/navigation.test.ts deleted file mode 100644 index db7e963da..000000000 --- a/app/tests/src/navigation.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. - -describe('navigation', () => { - it('should have the correct navigation screens', () => { - const navigationScreens = require('@/navigation').navigationScreens; - const listOfScreens = Object.keys(navigationScreens).sort(); - expect(listOfScreens).toEqual([ - 'AadhaarUpload', - 'AadhaarUploadError', - 'AadhaarUploadSuccess', - 'AccountRecovery', - 'AccountRecoveryChoice', - 'AccountVerifiedSuccess', - 'CloudBackupSettings', - 'ComingSoon', - 'ConfirmBelonging', - 'CountryPicker', - 'CreateMock', - 'DeferredLinkingInfo', - 'DevFeatureFlags', - 'DevHapticFeedback', - 'DevLoadingScreen', - 'DevPrivateKey', - 'DevSettings', - 'Disclaimer', - 'DocumentCamera', - 'DocumentCameraTrouble', - 'DocumentDataInfo', - 'DocumentDataNotFound', - 'DocumentNFCMethodSelection', - 'DocumentNFCScan', - 'DocumentNFCTrouble', - 'DocumentOnboarding', - 'Home', - 'IDPicker', - 'IdDetails', - 'Launch', - 'Loading', - 'ManageDocuments', - 'MockDataDeepLink', - 'Modal', - 'ProofHistory', - 'ProofHistoryDetail', - 'ProofRequestStatus', - 'Prove', - 'QRCodeTrouble', - 'QRCodeViewFinder', - 'RecoverWithPhrase', - 'SaveRecoveryPhrase', - 'Settings', - 'ShowRecoveryPhrase', - 'Splash', - ]); - }); -}); diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx new file mode 100644 index 000000000..061fb07a0 --- /dev/null +++ b/app/tests/src/navigation.test.tsx @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; + +jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn()); +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(() => ({})), +})); +jest.mock('@/utils/deeplinks', () => ({ + setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()), +})); +jest.mock('@/utils/analytics', () => + jest.fn(() => ({ + trackScreenView: jest.fn(), + })), +); + +describe('navigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should have the correct navigation screens', () => { + // Unmock @/navigation for this test to get the real navigationScreens + jest.unmock('@/navigation'); + jest.isolateModules(() => { + const navigationScreens = require('@/navigation').navigationScreens; + const listOfScreens = Object.keys(navigationScreens).sort(); + expect(listOfScreens).toEqual([ + 'AadhaarUpload', + 'AadhaarUploadError', + 'AadhaarUploadSuccess', + 'AccountRecovery', + 'AccountRecoveryChoice', + 'AccountVerifiedSuccess', + 'CloudBackupSettings', + 'ComingSoon', + 'ConfirmBelonging', + 'CountryPicker', + 'CreateMock', + 'DeferredLinkingInfo', + 'DevFeatureFlags', + 'DevHapticFeedback', + 'DevLoadingScreen', + 'DevPrivateKey', + 'DevSettings', + 'Disclaimer', + 'DocumentCamera', + 'DocumentCameraTrouble', + 'DocumentDataInfo', + 'DocumentDataNotFound', + 'DocumentNFCMethodSelection', + 'DocumentNFCScan', + 'DocumentNFCTrouble', + 'DocumentOnboarding', + 'Home', + 'IDPicker', + 'IdDetails', + 'Launch', + 'Loading', + 'ManageDocuments', + 'MockDataDeepLink', + 'Modal', + 'ProofHistory', + 'ProofHistoryDetail', + 'ProofRequestStatus', + 'Prove', + 'QRCodeTrouble', + 'QRCodeViewFinder', + 'RecoverWithPhrase', + 'SaveRecoveryPhrase', + 'Settings', + 'ShowRecoveryPhrase', + 'Splash', + ]); + }); + }); + + it('wires recovery prompts hook into navigation', () => { + // Temporarily restore the React mock and unmock @/navigation for this test + jest.unmock('@/navigation'); + const useRecoveryPrompts = + require('@/hooks/useRecoveryPrompts') as jest.Mock; + + // Since we're testing the wiring and not the actual rendering, + // we can just check if the module exports the default component + // and verify the hook is called when the component is imported + const navigation = require('@/navigation'); + expect(navigation.default).toBeDefined(); + + // Render the component to trigger the hooks + const NavigationWithTracking = navigation.default; + render(); + + expect(useRecoveryPrompts).toHaveBeenCalledWith({ + allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES, + }); + }); +});