SELF-483: Enable backup recovery prompts (#834)

* Guard recovery prompts

* refactor(app): gate recovery prompts with allow list (#1251)

* fix typing

* fix header

* fix app loading

* fix tests

* Limit recovery prompts to home allowlist (#1460)

* fix test

* fix typing pipeline

* format and fix linting and tests

* tests pass

* fix tests

* split up testing

* save wip

* save button fix

* fix count

* fix modal width

* remove consologging

* remove depcrecated login count

* linting

* lint

* early return
This commit is contained in:
Justin Hernandez
2025-12-05 21:34:50 -08:00
committed by GitHub
parent 89b16486ee
commit 202d0f8122
21 changed files with 1112 additions and 561 deletions

View File

@@ -87,7 +87,7 @@ GEM
commander (4.6.0) commander (4.6.0)
highline (~> 2.0.0) highline (~> 2.0.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.5) connection_pool (3.0.2)
declarative (0.0.20) declarative (0.0.20)
digest-crc (0.7.0) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
@@ -222,7 +222,7 @@ GEM
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.16.0) json (2.17.1)
jwt (2.10.2) jwt (2.10.2)
base64 base64
logger (1.7.0) logger (1.7.0)

View File

@@ -31,6 +31,7 @@ module.exports = {
moduleNameMapper: { moduleNameMapper: {
'^@env$': '<rootDir>/tests/__setup__/@env.js', '^@env$': '<rootDir>/tests/__setup__/@env.js',
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js', '\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
'^@$': '<rootDir>/src', '^@$': '<rootDir>/src',
'^@tests/(.*)$': '<rootDir>/tests/src/$1', '^@tests/(.*)$': '<rootDir>/tests/src/$1',

View File

@@ -21,21 +21,136 @@ const mockPixelRatio = {
global.PixelRatio = mockPixelRatio; global.PixelRatio = mockPixelRatio;
// Also make it available for require() calls // Define NativeModules early so it's available for react-native mock
const Module = require('module'); // This will be assigned to global.NativeModules later, but we define it here
// so the react-native mock can reference it
const originalRequire = Module.prototype.require; const NativeModules = {
Module.prototype.require = function (id) { PassportReader: {
if (id === 'react-native') { configure: jest.fn(),
const RN = originalRequire.apply(this, arguments); scanPassport: jest.fn(),
if (!RN.PixelRatio || !RN.PixelRatio.getFontScale) { trackEvent: jest.fn(),
RN.PixelRatio = mockPixelRatio; flush: jest.fn(),
} reset: jest.fn(),
return RN; },
} ReactNativeBiometrics: {
return originalRequire.apply(this, arguments); isSensorAvailable: jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
}),
createKeys: jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' }),
deleteKeys: jest.fn().mockResolvedValue(true),
createSignature: jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' }),
simplePrompt: jest.fn().mockResolvedValue({ success: true }),
},
NativeLoggerBridge: {
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
RNPassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
}; };
// Assign to global so it's available everywhere
global.NativeModules = NativeModules;
// Mock react-native comprehensively - single source of truth for all tests
// Note: NativeModules will be defined later and assigned to global.NativeModules
// This mock accesses it at runtime via global.NativeModules
jest.mock('react-native', () => {
// Create AppState mock with listener tracking
// Expose listeners array globally so tests can access it
const appStateListeners = [];
global.mockAppStateListeners = appStateListeners;
const mockAppState = {
currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
};
return {
__esModule: true,
AppState: mockAppState,
Platform: {
OS: 'ios',
select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
},
// NativeModules is defined above and assigned to global.NativeModules
// Use getter to access it at runtime (jest.mock is hoisted)
get NativeModules() {
return global.NativeModules || {};
},
NativeEventEmitter: jest.fn().mockImplementation(nativeModule => {
return {
addListener: jest.fn(),
removeListener: jest.fn(),
removeAllListeners: jest.fn(),
emit: jest.fn(),
};
}),
PixelRatio: mockPixelRatio,
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
Linking: {
getInitialURL: jest.fn().mockResolvedValue(null),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
openURL: jest.fn().mockResolvedValue(undefined),
canOpenURL: jest.fn().mockResolvedValue(true),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
TouchableHighlight: 'TouchableHighlight',
Image: 'Image',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
requireNativeComponent: jest.fn(name => {
// Return a mock component function for any native component
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
};
});
require('react-native-gesture-handler/jestSetup'); require('react-native-gesture-handler/jestSetup');
// Mock NativeAnimatedHelper - using virtual mock during RN 0.76.9 prep phase // Mock NativeAnimatedHelper - using virtual mock during RN 0.76.9 prep phase
@@ -56,6 +171,23 @@ global.__fbBatchedBridgeConfig = {
// Set up global React Native test environment // Set up global React Native test environment
global.__DEV__ = true; global.__DEV__ = true;
// Set up global mock navigation ref for tests
global.mockNavigationRef = {
isReady: jest.fn(() => true),
getCurrentRoute: jest.fn(() => ({ name: 'Home' })),
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
addListener: jest.fn(() => jest.fn()),
removeListener: jest.fn(),
};
// Load grouped mocks
require('./tests/__setup__/mocks/navigation');
require('./tests/__setup__/mocks/ui');
// Mock TurboModuleRegistry to provide required native modules for BOTH main app and mobile-sdk-alpha // Mock TurboModuleRegistry to provide required native modules for BOTH main app and mobile-sdk-alpha
jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({ jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
getEnforcing: jest.fn(name => { getEnforcing: jest.fn(name => {
@@ -130,21 +262,44 @@ jest.mock(
startDetecting: jest.fn(), startDetecting: jest.fn(),
}; };
const RN = jest.requireActual('react-native'); // Return a simple object with all the mocks we need
// Override the PixelRatio immediately // Avoid nested requireActual/requireMock to prevent OOM in CI
RN.PixelRatio = PixelRatio; return {
__esModule: true,
// Make sure both the default and named exports work
const mockedRN = {
...RN,
PixelRatio, PixelRatio,
default: { Platform: {
...RN, OS: 'ios',
PixelRatio, select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
}, },
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
requireNativeComponent: jest.fn(name => {
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
}; };
return mockedRN;
}, },
{ virtual: true }, { virtual: true },
); );
@@ -296,12 +451,19 @@ jest.mock('react-native-gesture-handler', () => {
const MockFlatList = jest.fn(props => null); const MockFlatList = jest.fn(props => null);
return { return {
...jest.requireActual('react-native-gesture-handler/jestSetup'), // Provide gesture handler mock without requireActual to avoid OOM
GestureHandlerRootView: ({ children }) => children, GestureHandlerRootView: ({ children }) => children,
ScrollView: MockScrollView, ScrollView: MockScrollView,
TouchableOpacity: MockTouchableOpacity, TouchableOpacity: MockTouchableOpacity,
TouchableHighlight: MockTouchableHighlight, TouchableHighlight: MockTouchableHighlight,
FlatList: MockFlatList, FlatList: MockFlatList,
Directions: {},
State: {},
Swipeable: jest.fn(() => null),
DrawerLayout: jest.fn(() => null),
PanGestureHandler: jest.fn(() => null),
TapGestureHandler: jest.fn(() => null),
LongPressGestureHandler: jest.fn(() => null),
}; };
}); });
@@ -750,20 +912,8 @@ jest.mock('react-native-passport-reader', () => {
}; };
}); });
// Mock NativeModules without requiring react-native to avoid memory issues // NativeModules is already defined at the top of the file and assigned to global.NativeModules
// Create a minimal NativeModules mock for PassportReader // No need to redefine it here
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Make it available globally for any code that expects it
global.NativeModules = NativeModules;
// Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests // Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests
jest.mock('./src/integrations/nfc/passportReader', () => { jest.mock('./src/integrations/nfc/passportReader', () => {
@@ -1006,226 +1156,60 @@ jest.mock('react-native-svg', () => {
}); });
// Mock React Navigation // Mock React Navigation
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native'); // Mock react-native-biometrics to prevent NativeModules errors
jest.mock('react-native-biometrics', () => {
class MockReactNativeBiometrics {
constructor(options) {
// Constructor accepts options but doesn't need to do anything
}
isSensorAvailable = jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
});
createKeys = jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' });
deleteKeys = jest.fn().mockResolvedValue(true);
createSignature = jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' });
simplePrompt = jest.fn().mockResolvedValue({ success: true });
}
return { return {
...actualNav, __esModule: true,
useFocusEffect: jest.fn(callback => { default: MockReactNativeBiometrics,
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
createNavigationContainerRef: jest.fn(() => ({
current: null,
getCurrentRoute: jest.fn(),
})),
createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })),
}; };
}); });
jest.mock('@react-navigation/native-stack', () => ({ // Mock NativeAppState native module to prevent getCurrentAppState errors
createNativeStackNavigator: jest.fn(() => ({ jest.mock('react-native/Libraries/AppState/NativeAppState', () => ({
displayName: 'MockStackNavigator', __esModule: true,
})), default: {
createNavigatorFactory: jest.fn(), getConstants: jest.fn(() => ({ initialAppState: 'active' })),
getCurrentAppState: jest.fn(() => Promise.resolve({ app_state: 'active' })),
addListener: jest.fn(),
removeListeners: jest.fn(),
},
})); }));
// Mock core navigation to avoid requiring a NavigationContainer for hooks // Mock AppState to prevent getCurrentAppState errors
jest.mock('@react-navigation/core', () => { jest.mock('react-native/Libraries/AppState/AppState', () => {
const actualCore = jest.requireActual('@react-navigation/core'); // Use the global appStateListeners array so tests can access it
return { const appStateListeners = global.mockAppStateListeners || [];
...actualCore,
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
};
});
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
return { return {
__esModule: true, __esModule: true,
default: MockWebView, default: {
WebView: MockWebView, currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
},
}; };
}); });
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});

View File

@@ -35,6 +35,7 @@
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..", "install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..",
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs", "ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test", "ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
"jest:clear": "node ./node_modules/jest/bin/jest.js --clearCache",
"jest:run": "node ./node_modules/jest/bin/jest.js", "jest:run": "node ./node_modules/jest/bin/jest.js",
"lint": "eslint . --cache --cache-location .eslintcache", "lint": "eslint . --cache --cache-location .eslintcache",
"lint:fix": "eslint --fix . --cache --cache-location .eslintcache", "lint:fix": "eslint --fix . --cache --cache-location .eslintcache",

View File

@@ -0,0 +1,8 @@
// 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.
export type RecoveryPromptAllowedRoute =
(typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number];
export const RECOVERY_PROMPT_ALLOWED_ROUTES = ['Home'] as const;

View File

@@ -3,10 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useRef, useState } from 'react'; 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 type { ModalParams } from '@/screens/app/ModalScreen';
import { import {
getModalCallbacks, getModalCallbacks,
@@ -16,23 +14,47 @@ import {
export const useModal = (params: ModalParams) => { export const useModal = (params: ModalParams) => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const callbackIdRef = useRef<number>(); const callbackIdRef = useRef<number>();
const handleModalDismiss = useCallback(() => {
setVisible(false);
params.onModalDismiss();
}, [params]);
const handleModalButtonPress = useCallback(() => {
setVisible(false);
return params.onButtonPress();
}, [params]);
const showModal = useCallback(() => { const showModal = useCallback(() => {
if (!navigationRef.isReady()) {
// Navigation not ready yet; avoid throwing and simply skip showing
return;
}
setVisible(true); setVisible(true);
const { onButtonPress, onModalDismiss, ...rest } = params; const {
const id = registerModalCallbacks({ onButtonPress, onModalDismiss }); // eslint-disable-next-line @typescript-eslint/no-unused-vars
onButtonPress: _ignored,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onModalDismiss: _ignored2,
...rest
} = params;
const id = registerModalCallbacks({
onButtonPress: handleModalButtonPress,
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id; callbackIdRef.current = id;
navigation.navigate('Modal', { ...rest, callbackId: id }); navigationRef.navigate('Modal', { ...rest, callbackId: id });
}, [params, navigation]); }, [handleModalButtonPress, handleModalDismiss, params]);
const dismissModal = useCallback(() => { const dismissModal = useCallback(() => {
setVisible(false); setVisible(false);
const routes = navigation.getState()?.routes; if (!navigationRef.isReady()) {
return;
}
const routes = navigationRef.getState()?.routes;
if (routes?.at(routes.length - 1)?.name === 'Modal') { if (routes?.at(routes.length - 1)?.name === 'Modal') {
navigation.goBack(); navigationRef.goBack();
} }
if (callbackIdRef.current !== undefined) { if (callbackIdRef.current !== undefined) {
const callbacks = getModalCallbacks(callbackIdRef.current); const callbacks = getModalCallbacks(callbackIdRef.current);
@@ -47,7 +69,7 @@ export const useModal = (params: ModalParams) => {
unregisterModalCallbacks(callbackIdRef.current); unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined; callbackIdRef.current = undefined;
} }
}, [navigation]); }, []);
return { return {
showModal, showModal,

View File

@@ -2,64 +2,167 @@
// SPDX-License-Identifier: BUSL-1.1 // SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. // 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 { useModal } from '@/hooks/useModal';
import { navigationRef } from '@/navigation'; import { navigationRef } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider'; import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore'; import { useSettingStore } from '@/stores/settingStore';
// TODO: need to debug and test the logic. it pops up too often. const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES;
export default function useRecoveryPrompts() {
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } = type UseRecoveryPromptsOptions = {
allowedRoutes?: readonly string[];
};
export default function useRecoveryPrompts({
allowedRoutes = DEFAULT_ALLOWED_ROUTES,
}: UseRecoveryPromptsOptions = {}) {
const { homeScreenViewCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
useSettingStore(); useSettingStore();
const { getAllDocuments } = usePassport(); const { getAllDocuments } = usePassport();
const hasRecoveryEnabled = cloudBackupEnabled || hasViewedRecoveryPhrase;
const { showModal, visible } = useModal({ const { showModal, visible } = useModal({
titleText: 'Protect your account', titleText: 'Protect your account',
bodyText: bodyText:
'Enable cloud backup or save your recovery phrase so you can recover your account.', 'Enable cloud backup or save your recovery phrase so you can recover your account.',
buttonText: 'Back up now', buttonText: 'Back up now',
onButtonPress: async () => { onButtonPress: async () => {
if (navigationRef.isReady()) { if (!navigationRef.isReady()) {
navigationRef.navigate('CloudBackupSettings', { return;
nextScreen: 'SaveRecoveryPhrase',
});
} }
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
}, },
onModalDismiss: () => {}, onModalDismiss: () => {},
} as const); } as const);
useEffect(() => { const lastPromptCount = useRef<number | null>(null);
async function maybePrompt() { const appStateStatus = useRef<AppStateStatus>(
if (!navigationRef.isReady()) { (AppState.currentState as AppStateStatus | null) ?? 'active',
);
const allowedRouteSet = useMemo(
() => new Set(allowedRoutes),
[allowedRoutes],
);
const isRouteEligible = useCallback(
(routeName: string | undefined): routeName is string => {
if (!routeName) {
return false;
}
if (!allowedRouteSet.has(routeName)) {
return false;
}
return true;
},
[allowedRouteSet],
);
const maybePrompt = useCallback(async () => {
if (!navigationRef.isReady()) {
return;
}
if (appStateStatus.current !== 'active') {
return;
}
const currentRouteName = navigationRef.getCurrentRoute?.()?.name;
if (!isRouteEligible(currentRouteName)) {
return;
}
if (hasRecoveryEnabled) {
return;
}
try {
const docs = await getAllDocuments();
const hasRegisteredDocument = Object.values(docs).some(
doc => doc.metadata.isRegistered === true,
);
if (!hasRegisteredDocument) {
return; return;
} }
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) { const shouldPrompt =
try { homeScreenViewCount >= 5 && homeScreenViewCount % 5 === 0;
const docs = await getAllDocuments();
if (Object.keys(docs).length === 0) { if (
return; shouldPrompt &&
} !visible &&
const shouldPrompt = lastPromptCount.current !== homeScreenViewCount
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); ) {
if (shouldPrompt) { // Double-check route eligibility right before showing modal
showModal(); // to prevent showing on wrong screen if user navigated during async call
} const currentRouteNameAfterAsync =
} catch { navigationRef.getCurrentRoute?.()?.name;
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt if (isRouteEligible(currentRouteNameAfterAsync)) {
return; showModal();
lastPromptCount.current = homeScreenViewCount;
} }
} }
} 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,
hasViewedRecoveryPhrase,
showModal,
getAllDocuments, getAllDocuments,
hasRecoveryEnabled,
homeScreenViewCount,
isRouteEligible,
showModal,
visible,
]); ]);
useEffect(() => {
const runMaybePrompt = () => {
maybePrompt().catch(() => {
// Ignore promise rejection - already handled in maybePrompt
});
};
runMaybePrompt();
const handleAppStateChange = (nextState: AppStateStatus) => {
const previousState = appStateStatus.current;
appStateStatus.current = nextState;
if (
(previousState === 'background' || previousState === 'inactive') &&
nextState === 'active'
) {
runMaybePrompt();
}
};
const appStateSubscription = AppState.addEventListener(
'change',
handleAppStateChange,
);
const navigationUnsubscribe = navigationRef.addListener?.(
'state',
runMaybePrompt,
);
return () => {
appStateSubscription.remove();
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
}, [maybePrompt]);
return { visible }; return { visible };
} }

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1 // SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Buffer } from 'buffer';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import type { PassportData } from '@selfxyz/common/types'; import type { PassportData } from '@selfxyz/common/types';

View File

@@ -17,6 +17,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { DefaultNavBar } from '@/components/navbar'; import { DefaultNavBar } from '@/components/navbar';
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import AppLayout from '@/layouts/AppLayout'; import AppLayout from '@/layouts/AppLayout';
import accountScreens from '@/navigation/account'; import accountScreens from '@/navigation/account';
import appScreens from '@/navigation/app'; import appScreens from '@/navigation/app';
@@ -198,6 +199,7 @@ const { trackScreenView } = analytics();
const Navigation = createStaticNavigation(AppNavigation); const Navigation = createStaticNavigation(AppNavigation);
const NavigationWithTracking = () => { const NavigationWithTracking = () => {
useRecoveryPrompts();
const selfClient = useSelfClient(); const selfClient = useSelfClient();
const trackScreen = () => { const trackScreen = () => {
const currentRoute = navigationRef.getCurrentRoute(); const currentRoute = navigationRef.getCurrentRoute();

View File

@@ -260,7 +260,6 @@ export const AuthProvider = ({
setIsAuthenticatingPromise(null); setIsAuthenticatingPromise(null);
setIsAuthenticated(true); setIsAuthenticated(true);
useSettingStore.getState().incrementLoginCount();
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS); trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
setAuthenticatedTimeout(previousTimeout => { setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) { if (previousTimeout) {

View File

@@ -65,32 +65,26 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
return; return;
} }
// Dismiss the modal BEFORE calling the callback
// This prevents race conditions when the callback navigates to another screen
try { try {
// Try to execute the callback first navigation.goBack();
await callbacks.onButtonPress(); unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error while dismissing modal:',
navigationError,
);
// Don't execute callback if modal couldn't be dismissed
return;
}
try { // Now execute the callback (which may navigate to another screen)
// If callback succeeds, try to navigate back // This only runs if dismissal succeeded
navigation.goBack(); try {
// Only unregister after successful navigation await callbacks.onButtonPress();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error('Navigation error:', navigationError);
// Don't cleanup if navigation fails - modal might still be visible
}
} catch (callbackError) { } catch (callbackError) {
console.error('Callback error:', callbackError); console.error('Callback error:', callbackError);
// If callback fails, we should still try to navigate and cleanup
try {
navigation.goBack();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error after callback failure:',
navigationError,
);
// Don't cleanup if navigation fails
}
} }
}, [callbacks, navigation, params.callbackId]); }, [callbacks, navigation, params.callbackId]);
@@ -108,6 +102,9 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
padding={20} padding={20}
borderRadius={10} borderRadius={10}
marginHorizontal={8} marginHorizontal={8}
width="79.5%"
maxWidth={460}
alignSelf="center"
> >
<YStack gap={40}> <YStack gap={40}>
<XStack alignItems="center" justifyContent="space-between"> <XStack alignItems="center" justifyContent="space-between">

View File

@@ -47,6 +47,7 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow'; import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
import type { RootStackParamList } from '@/navigation'; import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider'; import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import useUserStore from '@/stores/userStore'; import useUserStore from '@/stores/userStore';
const HomeScreen: React.FC = () => { const HomeScreen: React.FC = () => {
@@ -67,6 +68,7 @@ const HomeScreen: React.FC = () => {
Record<string, { data: IDDocument; metadata: DocumentMetadata }> Record<string, { data: IDDocument; metadata: DocumentMetadata }>
>({}); >({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const { amount: selfPoints } = usePoints(); const { amount: selfPoints } = usePoints();
@@ -124,6 +126,21 @@ const HomeScreen: React.FC = () => {
}, [loadDocuments]), }, [loadDocuments]),
); );
useFocusEffect(
useCallback(() => {
if (hasIncrementedOnFocus.current) {
return;
}
hasIncrementedOnFocus.current = true;
useSettingStore.getState().incrementHomeScreenViewCount();
return () => {
hasIncrementedOnFocus.current = false;
};
}, []),
);
useFocusEffect(() => { useFocusEffect(() => {
if (isNewVersionAvailable && !isModalDismissed) { if (isNewVersionAvailable && !isModalDismissed) {
showAppUpdateModal(); showAppUpdateModal();

View File

@@ -7,34 +7,34 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
interface PersistedSettingsState { interface PersistedSettingsState {
hasPrivacyNoteBeenDismissed: boolean; addSubscribedTopic: (topic: string) => void;
dismissPrivacyNote: () => void;
biometricsAvailable: boolean; biometricsAvailable: boolean;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
cloudBackupEnabled: boolean; cloudBackupEnabled: boolean;
toggleCloudBackupEnabled: () => void; dismissPrivacyNote: () => void;
loginCount: number;
incrementLoginCount: () => void;
hasViewedRecoveryPhrase: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
isDevMode: boolean;
setDevModeOn: () => void;
setDevModeOff: () => void;
hasCompletedKeychainMigration: boolean;
setKeychainMigrationCompleted: () => void;
fcmToken: string | null; fcmToken: string | null;
hasCompletedBackupForPoints: boolean;
hasCompletedKeychainMigration: boolean;
hasPrivacyNoteBeenDismissed: boolean;
hasViewedRecoveryPhrase: boolean;
homeScreenViewCount: number;
incrementHomeScreenViewCount: () => void;
isDevMode: boolean;
pointsAddress: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
setBackupForPointsCompleted: () => void;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
setDevModeOff: () => void;
setDevModeOn: () => void;
setFcmToken: (token: string | null) => void; setFcmToken: (token: string | null) => void;
turnkeyBackupEnabled: boolean; setHasViewedRecoveryPhrase: (viewed: boolean) => void;
setKeychainMigrationCompleted: () => void;
setPointsAddress: (address: string | null) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void; setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
subscribedTopics: string[]; subscribedTopics: string[];
setSubscribedTopics: (topics: string[]) => void; toggleCloudBackupEnabled: () => void;
addSubscribedTopic: (topic: string) => void; turnkeyBackupEnabled: boolean;
removeSubscribedTopic: (topic: string) => void;
hasCompletedBackupForPoints: boolean;
setBackupForPointsCompleted: () => void;
resetBackupForPoints: () => void;
pointsAddress: string | null;
setPointsAddress: (address: string | null) => void;
} }
interface NonPersistedSettingsState { interface NonPersistedSettingsState {
@@ -64,20 +64,33 @@ export const useSettingStore = create<SettingsState>()(
toggleCloudBackupEnabled: () => toggleCloudBackupEnabled: () =>
set(oldState => ({ set(oldState => ({
cloudBackupEnabled: !oldState.cloudBackupEnabled, cloudBackupEnabled: !oldState.cloudBackupEnabled,
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0, homeScreenViewCount: oldState.cloudBackupEnabled
? oldState.homeScreenViewCount
: 0,
})), })),
loginCount: 0, homeScreenViewCount: 0,
incrementLoginCount: () => incrementHomeScreenViewCount: () =>
set(oldState => ({ loginCount: oldState.loginCount + 1 })), set(oldState => {
if (
oldState.cloudBackupEnabled ||
oldState.hasViewedRecoveryPhrase === true
) {
return oldState;
}
const nextCount = oldState.homeScreenViewCount + 1;
return {
homeScreenViewCount: nextCount >= 100 ? 0 : nextCount,
};
}),
hasViewedRecoveryPhrase: false, hasViewedRecoveryPhrase: false,
setHasViewedRecoveryPhrase: viewed => setHasViewedRecoveryPhrase: viewed =>
set(oldState => ({ set(oldState => ({
hasViewedRecoveryPhrase: viewed, hasViewedRecoveryPhrase: viewed,
loginCount: homeScreenViewCount:
viewed && !oldState.hasViewedRecoveryPhrase viewed && !oldState.hasViewedRecoveryPhrase
? 0 ? 0
: oldState.loginCount, : oldState.homeScreenViewCount,
})), })),
isDevMode: false, isDevMode: false,

View File

@@ -0,0 +1,6 @@
// 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.
// Mock for image files in Jest tests
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,71 @@
// 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.
// Grouped navigation mocks to avoid cluttering jest.setup.js
jest.mock('@react-navigation/native', () => {
// Avoid nested requireActual to prevent OOM in CI
// Create mock navigator without requiring React
const MockNavigator = (props, _ref) => props.children;
MockNavigator.displayName = 'MockNavigator';
return {
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
createNavigationContainerRef: jest.fn(() => global.mockNavigationRef),
createStaticNavigation: jest.fn(() => MockNavigator),
NavigationContainer: ({ children }) => children,
DefaultTheme: {},
DarkTheme: {},
};
});
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(config => config),
createNavigatorFactory: jest.fn(),
}));
// Mock core navigation to avoid requiring a NavigationContainer for hooks
jest.mock('@react-navigation/core', () => {
// Avoid nested requireActual to prevent OOM in CI
return {
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
NavigationContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
NavigationRouteContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
};
});

View File

@@ -0,0 +1,199 @@
// 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.
// UI-related mocks grouped to keep jest.setup.js concise
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
return {
__esModule: true,
default: MockWebView,
WebView: MockWebView,
};
});
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});
// Mock screens that use mobile-sdk-alpha flows with PixelRatio issues or missing dependencies
jest.mock('@/screens/documents/selection/ConfirmBelongingScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockConfirmBelongingScreen';
return { __esModule: true, default: MockScreen };
});
jest.mock('@/screens/documents/selection/CountryPickerScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockCountryPickerScreen';
return { __esModule: true, default: MockScreen };
});

View File

@@ -2,32 +2,22 @@
// SPDX-License-Identifier: BUSL-1.1 // SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. // 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 { act, renderHook } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal'; import { useModal } from '@/hooks/useModal';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry'; 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', () => { describe('useModal', () => {
beforeEach(() => { beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue({ // Reset all mocks including the global navigationRef
navigate: mockNavigate, jest.clearAllMocks();
goBack: mockGoBack,
getState: mockGetState, // 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', () => { it('should navigate to Modal with callbackId and handle dismissal', () => {
@@ -45,8 +35,10 @@ describe('useModal', () => {
act(() => result.current.showModal()); act(() => result.current.showModal());
expect(mockNavigate).toHaveBeenCalledTimes(1); expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
const params = mockNavigate.mock.calls[0][1]; const [screenName, params] =
global.mockNavigationRef.navigate.mock.calls[0];
expect(screenName).toBe('Modal');
expect(params).toMatchObject({ expect(params).toMatchObject({
titleText: 'Title', titleText: 'Title',
bodyText: 'Body', bodyText: 'Body',
@@ -58,7 +50,7 @@ describe('useModal', () => {
act(() => result.current.dismissModal()); act(() => result.current.dismissModal());
expect(mockGoBack).toHaveBeenCalled(); expect(global.mockNavigationRef.goBack).toHaveBeenCalled();
expect(onModalDismiss).toHaveBeenCalled(); expect(onModalDismiss).toHaveBeenCalled();
expect(getModalCallbacks(id)).toBeUndefined(); expect(getModalCallbacks(id)).toBeUndefined();
}); });

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1 // SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { AppState } from 'react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native'; import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal'; import { useModal } from '@/hooks/useModal';
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import { usePassport } from '@/providers/passportDataProvider'; import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore'; import { useSettingStore } from '@/stores/settingStore';
const navigationStateListeners: Array<() => void> = [];
let isNavigationReady = true;
// Use global appStateListeners from jest.setup.js mock
const appStateListeners = global.mockAppStateListeners || [];
jest.mock('@/hooks/useModal'); jest.mock('@/hooks/useModal');
jest.mock('@/providers/passportDataProvider'); jest.mock('@/providers/passportDataProvider');
jest.mock('@/navigation', () => ({ jest.mock('@/navigation', () => ({
navigationRef: { navigationRef: global.mockNavigationRef,
isReady: jest.fn(() => true),
navigate: jest.fn(),
},
})); }));
// Use global react-native mock from jest.setup.js - no need to mock here
const showModal = jest.fn(); const showModal = jest.fn();
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
const getAllDocuments = jest.fn(); const getAllDocuments = jest.fn();
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments }); (usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
const getAppState = (): {
currentState: string;
addEventListener: jest.Mock;
} =>
AppState as unknown as {
currentState: string;
addEventListener: jest.Mock;
};
describe('useRecoveryPrompts', () => { describe('useRecoveryPrompts', () => {
beforeEach(() => { beforeEach(() => {
showModal.mockClear(); jest.clearAllMocks();
getAllDocuments.mockResolvedValue({ doc1: {} as any }); 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: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
const mockAppState = getAppState();
mockAppState.currentState = 'active';
act(() => { act(() => {
useSettingStore.setState({ useSettingStore.setState({
loginCount: 0, homeScreenViewCount: 0,
cloudBackupEnabled: false, cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false, hasViewedRecoveryPhrase: false,
}); });
}); });
}); });
it('shows modal on first login', async () => { it('does not show modal before the fifth home view', async () => {
act(() => { for (const count of [1, 2, 3, 4]) {
useSettingStore.setState({ loginCount: 1 }); showModal.mockClear();
}); act(() => {
renderHook(() => useRecoveryPrompts()); useSettingStore.setState({ homeScreenViewCount: count });
await waitFor(() => { });
expect(showModal).toHaveBeenCalled(); renderHook(() => useRecoveryPrompts());
}); await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
}); });
it('does not show modal when login count is 4', async () => { it('waits for navigation readiness before prompting', async () => {
isNavigationReady = false;
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
act(() => { act(() => {
useSettingStore.setState({ loginCount: 4 }); useSettingStore.setState({ homeScreenViewCount: 5 });
}); });
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
expect(showModal).not.toHaveBeenCalled(); expect(showModal).not.toHaveBeenCalled();
}); });
isNavigationReady = true;
navigationStateListeners.forEach(listener => listener());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
}); });
it('shows modal on eighth login', async () => { it('respects custom allow list overrides', async () => {
act(() => { act(() => {
useSettingStore.setState({ loginCount: 8 }); useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
showModal.mockClear();
global.mockNavigationRef.getCurrentRoute.mockReturnValue({
name: 'Settings',
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('prompts when returning from background on eligible route', async () => {
// This test verifies that the hook registers an app state listener
// and that the prompt logic can be triggered multiple times for different view counts
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
const { rerender, unmount } = renderHook(() => useRecoveryPrompts());
// Wait for initial prompt
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
// Clear and test with a different login count that should trigger again
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: 10 }); // next multiple of 5
});
rerender();
// Wait for second prompt with new login count
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
unmount();
});
it('does not show modal for non-multiple-of-five view counts', async () => {
for (const count of [6, 7, 8, 9]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('shows modal on fifth home view', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
}); });
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
it('does not show modal if backup already enabled', async () => { it('does not show modal if backup already enabled', async () => {
act(() => { act(() => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true }); useSettingStore.setState({
homeScreenViewCount: 5,
cloudBackupEnabled: true,
});
}); });
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
}); });
}); });
it('does not show modal when navigation is not ready', async () => { it('does not show modal if already visible', async () => {
const navigationRef = require('@/navigation').navigationRef; (useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
navigationRef.isReady.mockReturnValueOnce(false);
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
expect(showModal).not.toHaveBeenCalled(); expect(showModal).not.toHaveBeenCalled();
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when recovery phrase has been viewed', async () => { it('does not show modal when recovery phrase has been viewed', async () => {
act(() => { act(() => {
useSettingStore.setState({ useSettingStore.setState({
loginCount: 1, homeScreenViewCount: 5,
hasViewedRecoveryPhrase: true, hasViewedRecoveryPhrase: true,
}); });
}); });
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when no documents exist', async () => { it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({}); getAllDocuments.mockResolvedValueOnce({});
act(() => { act(() => {
useSettingStore.setState({ loginCount: 1 }); useSettingStore.setState({ homeScreenViewCount: 5 });
}); });
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
}); });
}); });
it('shows modal for other valid login counts', async () => { it('does not show modal when only unregistered documents exist', async () => {
for (const count of [2, 3, 13, 18]) { getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: undefined } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('shows modal when registered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal for other valid view counts (multiples of five)', async () => {
for (const count of [5, 10, 15]) {
showModal.mockClear(); showModal.mockClear();
act(() => { act(() => {
useSettingStore.setState({ loginCount: count }); useSettingStore.setState({ homeScreenViewCount: count });
}); });
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
await waitFor(() => { await waitFor(() => {
@@ -125,6 +284,32 @@ describe('useRecoveryPrompts', () => {
} }
}); });
it('does not show modal again for same login count when state changes', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
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', () => { it('returns correct visible state', () => {
const { result } = renderHook(() => useRecoveryPrompts()); const { result } = renderHook(() => useRecoveryPrompts());
expect(result.current.visible).toBe(false); expect(result.current.visible).toBe(false);
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
renderHook(() => useRecoveryPrompts()); renderHook(() => useRecoveryPrompts());
expect(useModal).toHaveBeenCalledWith({ expect(useModal).toHaveBeenCalledWith({
titleText: 'Protect your account', titleText: 'Protect your account',
bodyText: bodyText: expect.stringContaining(
'Enable cloud backup or save your recovery phrase so you can recover your account.', 'Enable cloud backup or save your recovery phrase so you can recover your account.',
),
buttonText: 'Back up now', buttonText: 'Back up now',
onButtonPress: expect.any(Function), onButtonPress: expect.any(Function),
onModalDismiss: expect.any(Function), onModalDismiss: expect.any(Function),

View File

@@ -0,0 +1,107 @@
// 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';
jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn());
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(() => ({})),
}));
jest.mock('@/navigation/deeplinks', () => ({
setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()),
}));
jest.mock('@/services/analytics', () => ({
__esModule: true,
default: jest.fn(() => ({
trackEvent: jest.fn(),
trackScreenView: jest.fn(),
flush: 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',
'Gratification',
'Home',
'IDPicker',
'IdDetails',
'Loading',
'ManageDocuments',
'MockDataDeepLink',
'Modal',
'Points',
'PointsInfo',
'ProofHistory',
'ProofHistoryDetail',
'ProofRequestStatus',
'Prove',
'QRCodeTrouble',
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',
'Splash',
'WebView',
]);
});
});
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(<NavigationWithTracking />);
expect(useRecoveryPrompts).toHaveBeenCalledWith();
});
});

View File

@@ -1,157 +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.
import { act } from '@testing-library/react-native';
import { useSettingStore } from '@/stores/settingStore';
describe('settingStore', () => {
beforeEach(() => {
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('increments login count', () => {
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
});
it('increments login count multiple times', () => {
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('increments login count from non-zero initial value', () => {
act(() => {
useSettingStore.setState({ loginCount: 5 });
});
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(6);
});
it('resets login count when recovery phrase viewed', () => {
act(() => {
useSettingStore.setState({ loginCount: 2 });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to false', () => {
act(() => {
useSettingStore.setState({
loginCount: 3,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('resets login count when enabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when disabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(4);
});
it('handles sequential actions that reset login count', () => {
// Increment login count
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle cloud backup (should reset to 0)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
// Increment again
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
// Set recovery phrase viewed (should reset to 0)
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
act(() => {
useSettingStore.setState({
loginCount: 5,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(5);
});
it('handles complex sequence of mixed operations', () => {
// Start with some increments
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
// Disable cloud backup (should not reset)
act(() => {
useSettingStore.setState({ cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Set recovery phrase viewed to false (should not reset)
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Enable cloud backup (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('maintains login count when toggling cloud backup from true to false then back to true', () => {
// Start with cloud backup enabled and some login count
act(() => {
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
});
// Toggle to disable (should not reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle to enable (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
});

View File

@@ -1,4 +1,5 @@
import forge from 'node-forge'; import forge from 'node-forge';
import { Buffer } from 'buffer';
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js'; import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
import { initElliptic } from '../utils/certificate_parsing/elliptic.js'; import { initElliptic } from '../utils/certificate_parsing/elliptic.js';