mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -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)
|
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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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.
|
// 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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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
|
// 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
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 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user