mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 06:38:09 -05:00
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:
@@ -87,7 +87,7 @@ GEM
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.5)
|
||||
connection_pool (3.0.2)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -222,7 +222,7 @@ GEM
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
|
||||
@@ -31,6 +31,7 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'^@env$': '<rootDir>/tests/__setup__/@env.js',
|
||||
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
|
||||
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@$': '<rootDir>/src',
|
||||
'^@tests/(.*)$': '<rootDir>/tests/src/$1',
|
||||
|
||||
@@ -21,21 +21,136 @@ const mockPixelRatio = {
|
||||
|
||||
global.PixelRatio = mockPixelRatio;
|
||||
|
||||
// Also make it available for require() calls
|
||||
const Module = require('module');
|
||||
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (id) {
|
||||
if (id === 'react-native') {
|
||||
const RN = originalRequire.apply(this, arguments);
|
||||
if (!RN.PixelRatio || !RN.PixelRatio.getFontScale) {
|
||||
RN.PixelRatio = mockPixelRatio;
|
||||
}
|
||||
return RN;
|
||||
}
|
||||
return originalRequire.apply(this, arguments);
|
||||
// Define NativeModules early so it's available for react-native mock
|
||||
// This will be assigned to global.NativeModules later, but we define it here
|
||||
// so the react-native mock can reference it
|
||||
const NativeModules = {
|
||||
PassportReader: {
|
||||
configure: jest.fn(),
|
||||
scanPassport: jest.fn(),
|
||||
trackEvent: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
},
|
||||
ReactNativeBiometrics: {
|
||||
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');
|
||||
|
||||
// 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
|
||||
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
|
||||
jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
|
||||
getEnforcing: jest.fn(name => {
|
||||
@@ -130,21 +262,44 @@ jest.mock(
|
||||
startDetecting: jest.fn(),
|
||||
};
|
||||
|
||||
const RN = jest.requireActual('react-native');
|
||||
// Override the PixelRatio immediately
|
||||
RN.PixelRatio = PixelRatio;
|
||||
|
||||
// Make sure both the default and named exports work
|
||||
const mockedRN = {
|
||||
...RN,
|
||||
// Return a simple object with all the mocks we need
|
||||
// Avoid nested requireActual/requireMock to prevent OOM in CI
|
||||
return {
|
||||
__esModule: true,
|
||||
PixelRatio,
|
||||
default: {
|
||||
...RN,
|
||||
PixelRatio,
|
||||
Platform: {
|
||||
OS: 'ios',
|
||||
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 },
|
||||
);
|
||||
@@ -296,12 +451,19 @@ jest.mock('react-native-gesture-handler', () => {
|
||||
const MockFlatList = jest.fn(props => null);
|
||||
|
||||
return {
|
||||
...jest.requireActual('react-native-gesture-handler/jestSetup'),
|
||||
// Provide gesture handler mock without requireActual to avoid OOM
|
||||
GestureHandlerRootView: ({ children }) => children,
|
||||
ScrollView: MockScrollView,
|
||||
TouchableOpacity: MockTouchableOpacity,
|
||||
TouchableHighlight: MockTouchableHighlight,
|
||||
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
|
||||
// Create a minimal NativeModules mock for PassportReader
|
||||
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;
|
||||
// NativeModules is already defined at the top of the file and assigned to global.NativeModules
|
||||
// No need to redefine it here
|
||||
|
||||
// Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests
|
||||
jest.mock('./src/integrations/nfc/passportReader', () => {
|
||||
@@ -1006,226 +1156,60 @@ jest.mock('react-native-svg', () => {
|
||||
});
|
||||
|
||||
// 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 {
|
||||
...actualNav,
|
||||
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(),
|
||||
})),
|
||||
createNavigationContainerRef: jest.fn(() => ({
|
||||
current: null,
|
||||
getCurrentRoute: jest.fn(),
|
||||
})),
|
||||
createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })),
|
||||
__esModule: true,
|
||||
default: MockReactNativeBiometrics,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@react-navigation/native-stack', () => ({
|
||||
createNativeStackNavigator: jest.fn(() => ({
|
||||
displayName: 'MockStackNavigator',
|
||||
})),
|
||||
createNavigatorFactory: jest.fn(),
|
||||
// Mock NativeAppState native module to prevent getCurrentAppState errors
|
||||
jest.mock('react-native/Libraries/AppState/NativeAppState', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
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
|
||||
jest.mock('@react-navigation/core', () => {
|
||||
const actualCore = jest.requireActual('@react-navigation/core');
|
||||
return {
|
||||
...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';
|
||||
// Mock AppState to prevent getCurrentAppState errors
|
||||
jest.mock('react-native/Libraries/AppState/AppState', () => {
|
||||
// Use the global appStateListeners array so tests can access it
|
||||
const appStateListeners = global.mockAppStateListeners || [];
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockWebView,
|
||||
WebView: MockWebView,
|
||||
default: {
|
||||
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 };
|
||||
});
|
||||
|
||||
@@ -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 ..",
|
||||
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
|
||||
"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",
|
||||
"lint": "eslint . --cache --cache-location .eslintcache",
|
||||
"lint:fix": "eslint --fix . --cache --cache-location .eslintcache",
|
||||
|
||||
8
app/src/consts/recoveryPrompts.ts
Normal file
8
app/src/consts/recoveryPrompts.ts
Normal 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;
|
||||
@@ -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,47 @@ import {
|
||||
|
||||
export const useModal = (params: ModalParams) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const callbackIdRef = useRef<number>();
|
||||
|
||||
const handleModalDismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
params.onModalDismiss();
|
||||
}, [params]);
|
||||
|
||||
const handleModalButtonPress = useCallback(() => {
|
||||
setVisible(false);
|
||||
return params.onButtonPress();
|
||||
}, [params]);
|
||||
|
||||
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 });
|
||||
const {
|
||||
// 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;
|
||||
navigation.navigate('Modal', { ...rest, callbackId: id });
|
||||
}, [params, navigation]);
|
||||
navigationRef.navigate('Modal', { ...rest, callbackId: id });
|
||||
}, [handleModalButtonPress, handleModalDismiss, 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 +69,7 @@ export const useModal = (params: ModalParams) => {
|
||||
unregisterModalCallbacks(callbackIdRef.current);
|
||||
callbackIdRef.current = undefined;
|
||||
}
|
||||
}, [navigation]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showModal,
|
||||
|
||||
@@ -2,64 +2,167 @@
|
||||
// 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 { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
|
||||
const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES;
|
||||
|
||||
type UseRecoveryPromptsOptions = {
|
||||
allowedRoutes?: readonly string[];
|
||||
};
|
||||
|
||||
export default function useRecoveryPrompts({
|
||||
allowedRoutes = DEFAULT_ALLOWED_ROUTES,
|
||||
}: UseRecoveryPromptsOptions = {}) {
|
||||
const { homeScreenViewCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
|
||||
useSettingStore();
|
||||
const { getAllDocuments } = usePassport();
|
||||
const hasRecoveryEnabled = cloudBackupEnabled || hasViewedRecoveryPhrase;
|
||||
|
||||
const { showModal, visible } = useModal({
|
||||
titleText: 'Protect your account',
|
||||
bodyText:
|
||||
'Enable cloud backup or save your recovery phrase so you can recover your account.',
|
||||
buttonText: 'Back up now',
|
||||
onButtonPress: async () => {
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('CloudBackupSettings', {
|
||||
nextScreen: 'SaveRecoveryPhrase',
|
||||
});
|
||||
if (!navigationRef.isReady()) {
|
||||
return;
|
||||
}
|
||||
navigationRef.navigate('CloudBackupSettings', {
|
||||
nextScreen: 'SaveRecoveryPhrase',
|
||||
});
|
||||
},
|
||||
onModalDismiss: () => {},
|
||||
} as const);
|
||||
|
||||
useEffect(() => {
|
||||
async function maybePrompt() {
|
||||
if (!navigationRef.isReady()) {
|
||||
const lastPromptCount = useRef<number | null>(null);
|
||||
const appStateStatus = useRef<AppStateStatus>(
|
||||
(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;
|
||||
}
|
||||
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 =
|
||||
homeScreenViewCount >= 5 && homeScreenViewCount % 5 === 0;
|
||||
|
||||
if (
|
||||
shouldPrompt &&
|
||||
!visible &&
|
||||
lastPromptCount.current !== homeScreenViewCount
|
||||
) {
|
||||
// Double-check route eligibility right before showing modal
|
||||
// to prevent showing on wrong screen if user navigated during async call
|
||||
const currentRouteNameAfterAsync =
|
||||
navigationRef.getCurrentRoute?.()?.name;
|
||||
|
||||
if (isRouteEligible(currentRouteNameAfterAsync)) {
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { Buffer } from 'buffer';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import type { PassportData } from '@selfxyz/common/types';
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
|
||||
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { DefaultNavBar } from '@/components/navbar';
|
||||
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import accountScreens from '@/navigation/account';
|
||||
import appScreens from '@/navigation/app';
|
||||
@@ -198,6 +199,7 @@ const { trackScreenView } = analytics();
|
||||
const Navigation = createStaticNavigation(AppNavigation);
|
||||
|
||||
const NavigationWithTracking = () => {
|
||||
useRecoveryPrompts();
|
||||
const selfClient = useSelfClient();
|
||||
const trackScreen = () => {
|
||||
const currentRoute = navigationRef.getCurrentRoute();
|
||||
|
||||
@@ -260,7 +260,6 @@ export const AuthProvider = ({
|
||||
|
||||
setIsAuthenticatingPromise(null);
|
||||
setIsAuthenticated(true);
|
||||
useSettingStore.getState().incrementLoginCount();
|
||||
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
|
||||
setAuthenticatedTimeout(previousTimeout => {
|
||||
if (previousTimeout) {
|
||||
|
||||
@@ -65,32 +65,26 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss the modal BEFORE calling the callback
|
||||
// This prevents race conditions when the callback navigates to another screen
|
||||
try {
|
||||
// Try to execute the callback first
|
||||
await callbacks.onButtonPress();
|
||||
navigation.goBack();
|
||||
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 {
|
||||
// If callback succeeds, try to navigate back
|
||||
navigation.goBack();
|
||||
// Only unregister after successful navigation
|
||||
unregisterModalCallbacks(params.callbackId);
|
||||
} catch (navigationError) {
|
||||
console.error('Navigation error:', navigationError);
|
||||
// Don't cleanup if navigation fails - modal might still be visible
|
||||
}
|
||||
// Now execute the callback (which may navigate to another screen)
|
||||
// This only runs if dismissal succeeded
|
||||
try {
|
||||
await callbacks.onButtonPress();
|
||||
} catch (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]);
|
||||
|
||||
@@ -108,6 +102,9 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
|
||||
padding={20}
|
||||
borderRadius={10}
|
||||
marginHorizontal={8}
|
||||
width="79.5%"
|
||||
maxWidth={460}
|
||||
alignSelf="center"
|
||||
>
|
||||
<YStack gap={40}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
|
||||
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
|
||||
const HomeScreen: React.FC = () => {
|
||||
@@ -67,6 +68,7 @@ const HomeScreen: React.FC = () => {
|
||||
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const hasIncrementedOnFocus = useRef(false);
|
||||
|
||||
const { amount: selfPoints } = usePoints();
|
||||
|
||||
@@ -124,6 +126,21 @@ const HomeScreen: React.FC = () => {
|
||||
}, [loadDocuments]),
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (hasIncrementedOnFocus.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasIncrementedOnFocus.current = true;
|
||||
useSettingStore.getState().incrementHomeScreenViewCount();
|
||||
|
||||
return () => {
|
||||
hasIncrementedOnFocus.current = false;
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
useFocusEffect(() => {
|
||||
if (isNewVersionAvailable && !isModalDismissed) {
|
||||
showAppUpdateModal();
|
||||
|
||||
@@ -7,34 +7,34 @@ import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface PersistedSettingsState {
|
||||
hasPrivacyNoteBeenDismissed: boolean;
|
||||
dismissPrivacyNote: () => void;
|
||||
addSubscribedTopic: (topic: string) => void;
|
||||
biometricsAvailable: boolean;
|
||||
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
|
||||
cloudBackupEnabled: boolean;
|
||||
toggleCloudBackupEnabled: () => void;
|
||||
loginCount: number;
|
||||
incrementLoginCount: () => void;
|
||||
hasViewedRecoveryPhrase: boolean;
|
||||
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
|
||||
isDevMode: boolean;
|
||||
setDevModeOn: () => void;
|
||||
setDevModeOff: () => void;
|
||||
hasCompletedKeychainMigration: boolean;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
dismissPrivacyNote: () => void;
|
||||
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;
|
||||
turnkeyBackupEnabled: boolean;
|
||||
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
setPointsAddress: (address: string | null) => void;
|
||||
setSubscribedTopics: (topics: string[]) => void;
|
||||
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
|
||||
subscribedTopics: string[];
|
||||
setSubscribedTopics: (topics: string[]) => void;
|
||||
addSubscribedTopic: (topic: string) => void;
|
||||
removeSubscribedTopic: (topic: string) => void;
|
||||
hasCompletedBackupForPoints: boolean;
|
||||
setBackupForPointsCompleted: () => void;
|
||||
resetBackupForPoints: () => void;
|
||||
pointsAddress: string | null;
|
||||
setPointsAddress: (address: string | null) => void;
|
||||
toggleCloudBackupEnabled: () => void;
|
||||
turnkeyBackupEnabled: boolean;
|
||||
}
|
||||
|
||||
interface NonPersistedSettingsState {
|
||||
@@ -64,20 +64,33 @@ export const useSettingStore = create<SettingsState>()(
|
||||
toggleCloudBackupEnabled: () =>
|
||||
set(oldState => ({
|
||||
cloudBackupEnabled: !oldState.cloudBackupEnabled,
|
||||
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0,
|
||||
homeScreenViewCount: oldState.cloudBackupEnabled
|
||||
? oldState.homeScreenViewCount
|
||||
: 0,
|
||||
})),
|
||||
|
||||
loginCount: 0,
|
||||
incrementLoginCount: () =>
|
||||
set(oldState => ({ loginCount: oldState.loginCount + 1 })),
|
||||
homeScreenViewCount: 0,
|
||||
incrementHomeScreenViewCount: () =>
|
||||
set(oldState => {
|
||||
if (
|
||||
oldState.cloudBackupEnabled ||
|
||||
oldState.hasViewedRecoveryPhrase === true
|
||||
) {
|
||||
return oldState;
|
||||
}
|
||||
const nextCount = oldState.homeScreenViewCount + 1;
|
||||
return {
|
||||
homeScreenViewCount: nextCount >= 100 ? 0 : nextCount,
|
||||
};
|
||||
}),
|
||||
hasViewedRecoveryPhrase: false,
|
||||
setHasViewedRecoveryPhrase: viewed =>
|
||||
set(oldState => ({
|
||||
hasViewedRecoveryPhrase: viewed,
|
||||
loginCount:
|
||||
homeScreenViewCount:
|
||||
viewed && !oldState.hasViewedRecoveryPhrase
|
||||
? 0
|
||||
: oldState.loginCount,
|
||||
: oldState.homeScreenViewCount,
|
||||
})),
|
||||
|
||||
isDevMode: false,
|
||||
|
||||
6
app/tests/__setup__/imageMock.js
Normal file
6
app/tests/__setup__/imageMock.js
Normal 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';
|
||||
71
app/tests/__setup__/mocks/navigation.js
Normal file
71
app/tests/__setup__/mocks/navigation.js
Normal 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),
|
||||
},
|
||||
};
|
||||
});
|
||||
199
app/tests/__setup__/mocks/ui.js
Normal file
199
app/tests/__setup__/mocks/ui.js
Normal 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 };
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// 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 { useModal } from '@/hooks/useModal';
|
||||
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
|
||||
import { usePassport } from '@/providers/passportDataProvider';
|
||||
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('@/providers/passportDataProvider');
|
||||
jest.mock('@/navigation', () => ({
|
||||
navigationRef: {
|
||||
isReady: jest.fn(() => true),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
navigationRef: global.mockNavigationRef,
|
||||
}));
|
||||
// Use global react-native mock from jest.setup.js - no need to mock here
|
||||
|
||||
const showModal = jest.fn();
|
||||
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
|
||||
const getAllDocuments = jest.fn();
|
||||
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
|
||||
|
||||
const getAppState = (): {
|
||||
currentState: string;
|
||||
addEventListener: jest.Mock;
|
||||
} =>
|
||||
AppState as unknown as {
|
||||
currentState: string;
|
||||
addEventListener: jest.Mock;
|
||||
};
|
||||
|
||||
describe('useRecoveryPrompts', () => {
|
||||
beforeEach(() => {
|
||||
showModal.mockClear();
|
||||
getAllDocuments.mockResolvedValue({ doc1: {} as any });
|
||||
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: {
|
||||
data: {} as any,
|
||||
metadata: { isRegistered: true } as any,
|
||||
},
|
||||
});
|
||||
const mockAppState = getAppState();
|
||||
mockAppState.currentState = 'active';
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 0,
|
||||
homeScreenViewCount: 0,
|
||||
cloudBackupEnabled: false,
|
||||
hasViewedRecoveryPhrase: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal on first login', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
it('does not show modal before the fifth home view', async () => {
|
||||
for (const count of [1, 2, 3, 4]) {
|
||||
showModal.mockClear();
|
||||
act(() => {
|
||||
useSettingStore.setState({ homeScreenViewCount: count });
|
||||
});
|
||||
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(() => {
|
||||
useSettingStore.setState({ loginCount: 4 });
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
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(() => {
|
||||
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());
|
||||
await waitFor(() => {
|
||||
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
|
||||
|
||||
it('does not show modal if backup already enabled', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
|
||||
useSettingStore.setState({
|
||||
homeScreenViewCount: 5,
|
||||
cloudBackupEnabled: true,
|
||||
});
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show modal when navigation is not ready', async () => {
|
||||
const navigationRef = require('@/navigation').navigationRef;
|
||||
navigationRef.isReady.mockReturnValueOnce(false);
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
});
|
||||
it('does not show modal if already visible', async () => {
|
||||
(useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
expect(showModal).not.toHaveBeenCalled();
|
||||
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
|
||||
it('does not show modal when recovery phrase has been viewed', async () => {
|
||||
act(() => {
|
||||
useSettingStore.setState({
|
||||
loginCount: 1,
|
||||
homeScreenViewCount: 5,
|
||||
hasViewedRecoveryPhrase: true,
|
||||
});
|
||||
});
|
||||
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
|
||||
it('does not show modal when no documents exist', async () => {
|
||||
getAllDocuments.mockResolvedValueOnce({});
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: 1 });
|
||||
useSettingStore.setState({ homeScreenViewCount: 5 });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
await waitFor(() => {
|
||||
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows modal for other valid login counts', async () => {
|
||||
for (const count of [2, 3, 13, 18]) {
|
||||
it('does not show modal when only unregistered documents exist', async () => {
|
||||
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();
|
||||
act(() => {
|
||||
useSettingStore.setState({ loginCount: count });
|
||||
useSettingStore.setState({ homeScreenViewCount: count });
|
||||
});
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
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', () => {
|
||||
const { result } = renderHook(() => useRecoveryPrompts());
|
||||
expect(result.current.visible).toBe(false);
|
||||
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
|
||||
renderHook(() => useRecoveryPrompts());
|
||||
expect(useModal).toHaveBeenCalledWith({
|
||||
titleText: 'Protect your account',
|
||||
bodyText:
|
||||
bodyText: expect.stringContaining(
|
||||
'Enable cloud backup or save your recovery phrase so you can recover your account.',
|
||||
),
|
||||
buttonText: 'Back up now',
|
||||
onButtonPress: expect.any(Function),
|
||||
onModalDismiss: expect.any(Function),
|
||||
|
||||
107
app/tests/src/navigation.test.tsx
Normal file
107
app/tests/src/navigation.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import forge from 'node-forge';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
|
||||
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';
|
||||
|
||||
Reference in New Issue
Block a user