From d2ad6fd624e16e8200851a3e6e7705285f0ba67a Mon Sep 17 00:00:00 2001 From: Andre Pimenta Date: Mon, 9 Dec 2024 20:20:42 +0000 Subject: [PATCH 1/3] docs: Update README.md with new expo instructions (#12617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adding new instructions for Expo: #### For internal developers - Access Runway via Okta and go to the Expo bucket either on the iOS or Android section. From there you will see the available development builds (android-expo-dev-build.apk or ios-expo-dev-build.ipa). - For Android: - Install the .apk on your Android device or simulator. - For iOS: - Device: you need to have your iPhone registered with our Apple dev account. If you have it, you can install the .ipa on your device. - Simulator: please follow the [native development section](https://github.com/MetaMask/metamask-mobile?tab=readme-ov-file#native-development) and run `yarn setup` and `yarn start:ios` as the .ipa will not work for now, we are working on having an .app that works on simulators. ##### [SOON] For external developers (we are testing the new dev builds and will make them publicly available soon after) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 638b073f580..2f47d76233d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ yarn watch #### Download and install the development build -Go to the app's [GitHub Releases page](https://github.com/MetaMask/metamask-mobile/releases), download the latest release development build (android-expo-dev-build.apk or ios-expo-dev-build.ipa) and install it on an Android/iOS simulator or Android/iOS physical device. +#### For internal developers +- Access Runway via Okta and go to the Expo bucket either on the iOS or Android section. From there you will see the available development builds (android-expo-dev-build.apk or ios-expo-dev-build.ipa). +- For Android: + - Install the .apk on your Android device or simulator. +- For iOS: + - Device: you need to have your iPhone registered with our Apple dev account. If you have it, you can install the .ipa on your device. + - Simulator: please follow the [native development section](https://github.com/MetaMask/metamask-mobile?tab=readme-ov-file#native-development) and run `yarn setup` and `yarn start:ios` as the .ipa will not work for now, we are working on having an .app that works on simulators. + +##### [SOON] For external developers (we are testing the new dev builds and will make them publicly available soon after) #### Load the app From 65e28fc696e2ec8e44b6c0fbedd36534db3b8f7c Mon Sep 17 00:00:00 2001 From: Cal Leung Date: Mon, 9 Dec 2024 13:43:52 -0800 Subject: [PATCH 2/3] chore: Chore/12435 mvp handle engine does not exist (#12538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The main purpose of these changes is to ensure that the navigation service is available when Engine is initialized. The reason that this is needed is because it allows us to redirect instances of failed controller instances to vault recovery where users may at least recover their keys as opposed to being bricked. This is an example of the bricked state - https://github.com/MetaMask/metamask-mobile/pull/12115. This PR reorganizes the initialization of services, including Engine to sagas, where it enables us to wait for dependencies to load first. The two dependencies that the Engine relies on are: - Persisted data loaded - Navigation loaded ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12435 ## **Manual testing steps** While the underlying logic changes, the app behavior should remain the same 1. Install the app on this branch 2. Create a wallet 3. Kill app, reopen and login 4. Should not experience any issues ## **Screenshots/Recordings** ### **Before** Simulates controller failed initialization https://github.com/user-attachments/assets/beb5e952-0fe3-4470-8aa6-2ff0947e3ffd ### **After** Simulates vault recovery when controller fails to initialize https://github.com/user-attachments/assets/76a853cd-34bc-465c-af03-d5c07609b8ee ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/CODEOWNERS | 2 +- app/actions/navigation/index.ts | 30 ++- app/actions/navigation/types.ts | 31 +++ app/actions/user/index.js | 134 ---------- app/actions/user/index.ts | 161 ++++++++++++ app/actions/user/types.ts | 111 +++++++++ app/components/Nav/App/index.js | 7 + .../DeleteWallet/useDeleteWallet.test.tsx | 6 + app/core/AppStateEventListener.test.ts | 50 ++-- app/core/AppStateEventListener.ts | 35 ++- .../Authentication/Authentication.test.ts | 28 +-- app/core/Authentication/Authentication.ts | 49 +--- app/core/Engine/Engine.ts | 4 +- .../constants.ts | 0 .../utils.test.ts | 0 .../{accounts => AccountsController}/utils.ts | 0 .../Engine/controllers/accounts/README.md | 3 - app/core/EngineService/EngineService.test.ts | 107 +++++--- app/core/EngineService/EngineService.ts | 59 +++-- app/core/EngineService/index.ts | 3 +- app/core/LockManagerService/index.test.ts | 234 ++++++++++-------- app/core/LockManagerService/index.ts | 90 ++++--- app/core/redux/ReduxService.test.ts | 67 +++++ app/core/redux/ReduxService.ts | 49 ++++ app/core/redux/index.ts | 3 + app/core/redux/types.ts | 8 + app/reducers/index.ts | 11 +- app/reducers/navigation/index.ts | 38 +-- app/reducers/navigation/selectors.ts | 23 ++ app/reducers/navigation/types.ts | 8 + app/reducers/user/index.ts | 78 +++--- app/reducers/user/selectors.ts | 6 + app/reducers/user/types.ts | 19 ++ app/store/index.ts | 37 +-- app/store/persistConfig.ts | 4 +- app/store/sagas/index.ts | 61 +++-- app/store/sagas/sagas.test.ts | 78 +++++- app/util/test/initial-root-state.ts | 3 +- babel.config.js | 6 + package.json | 1 + yarn.lock | 19 ++ 41 files changed, 1060 insertions(+), 603 deletions(-) create mode 100644 app/actions/navigation/types.ts delete mode 100644 app/actions/user/index.js create mode 100644 app/actions/user/index.ts create mode 100644 app/actions/user/types.ts rename app/core/Engine/controllers/{accounts => AccountsController}/constants.ts (100%) rename app/core/Engine/controllers/{accounts => AccountsController}/utils.test.ts (100%) rename app/core/Engine/controllers/{accounts => AccountsController}/utils.ts (100%) delete mode 100644 app/core/Engine/controllers/accounts/README.md create mode 100644 app/core/redux/ReduxService.test.ts create mode 100644 app/core/redux/ReduxService.ts create mode 100644 app/core/redux/index.ts create mode 100644 app/core/redux/types.ts create mode 100644 app/reducers/navigation/selectors.ts create mode 100644 app/reducers/navigation/types.ts create mode 100644 app/reducers/user/selectors.ts create mode 100644 app/reducers/user/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 169e5ccc3dc..9576fde1dd0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,7 +47,7 @@ app/util/walletconnect.js @MetaMask/sdk-devs # Accounts Team app/core/Encryptor/ @MetaMask/accounts-engineers -app/core/Engine/controllers/accounts @MetaMask/accounts-engineers +app/core/Engine/controllers/AccountsController @MetaMask/accounts-engineers # Swaps Team app/components/UI/Swaps @MetaMask/swaps-engineers diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index 7a6fac7e9a0..b4c82b9a98e 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,18 +1,28 @@ /* eslint-disable import/prefer-default-export */ import { - SET_CURRENT_ROUTE, - SET_CURRENT_BOTTOM_NAV_ROUTE, -} from '../../reducers/navigation'; + type OnNavigationReadyAction, + type SetCurrentRouteAction, + type SetCurrentBottomNavRouteAction, + NavigationActionType, +} from './types'; -/** - * Action Creators - */ -export const setCurrentRoute = (route: string) => ({ - type: SET_CURRENT_ROUTE, +export * from './types'; + +export const setCurrentRoute = (route: string): SetCurrentRouteAction => ({ + type: NavigationActionType.SET_CURRENT_ROUTE, payload: { route }, }); -export const setCurrentBottomNavRoute = (route: string) => ({ - type: SET_CURRENT_BOTTOM_NAV_ROUTE, +export const setCurrentBottomNavRoute = ( + route: string, +): SetCurrentBottomNavRouteAction => ({ + type: NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE, payload: { route }, }); + +/** + * Action that is called when navigation is ready + */ +export const onNavigationReady = (): OnNavigationReadyAction => ({ + type: NavigationActionType.ON_NAVIGATION_READY, +}); diff --git a/app/actions/navigation/types.ts b/app/actions/navigation/types.ts new file mode 100644 index 00000000000..c57beba69da --- /dev/null +++ b/app/actions/navigation/types.ts @@ -0,0 +1,31 @@ +import { type Action } from 'redux'; + +/** + * Navigation action type enum + */ +export enum NavigationActionType { + ON_NAVIGATION_READY = 'ON_NAVIGATION_READY', + SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE', + SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_BOTTOM_NAV_ROUTE', +} + +export type OnNavigationReadyAction = + Action; + +export type SetCurrentRouteAction = + Action & { + payload: { route: string }; + }; + +export type SetCurrentBottomNavRouteAction = + Action & { + payload: { route: string }; + }; + +/** + * Navigation action + */ +export type NavigationAction = + | OnNavigationReadyAction + | SetCurrentRouteAction + | SetCurrentBottomNavRouteAction; diff --git a/app/actions/user/index.js b/app/actions/user/index.js deleted file mode 100644 index fd996b8707f..00000000000 --- a/app/actions/user/index.js +++ /dev/null @@ -1,134 +0,0 @@ -// Constants -export const LOCKED_APP = 'LOCKED_APP'; -export const AUTH_SUCCESS = 'AUTH_SUCCESS'; -export const AUTH_ERROR = 'AUTH_ERROR'; -export const INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS'; -export const LOGIN = 'LOGIN'; -export const LOGOUT = 'LOGOUT'; - -export function interruptBiometrics() { - return { - type: INTERRUPT_BIOMETRICS, - }; -} - -export function lockApp() { - return { - type: LOCKED_APP, - }; -} - -export function authSuccess(bioStateMachineId) { - return { - type: AUTH_SUCCESS, - payload: { bioStateMachineId }, - }; -} - -export function authError(bioStateMachineId) { - return { - type: AUTH_ERROR, - payload: { bioStateMachineId }, - }; -} - -export function passwordSet() { - return { - type: 'PASSWORD_SET', - }; -} - -export function passwordUnset() { - return { - type: 'PASSWORD_UNSET', - }; -} - -export function seedphraseBackedUp() { - return { - type: 'SEEDPHRASE_BACKED_UP', - }; -} - -export function seedphraseNotBackedUp() { - return { - type: 'SEEDPHRASE_NOT_BACKED_UP', - }; -} - -export function backUpSeedphraseAlertVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_VISIBLE', - }; -} - -export function backUpSeedphraseAlertNotVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', - }; -} - -export function protectWalletModalVisible() { - return { - type: 'PROTECT_MODAL_VISIBLE', - }; -} - -export function protectWalletModalNotVisible() { - return { - type: 'PROTECT_MODAL_NOT_VISIBLE', - }; -} - -export function loadingSet(loadingMsg) { - return { - type: 'LOADING_SET', - loadingMsg, - }; -} - -export function loadingUnset() { - return { - type: 'LOADING_UNSET', - }; -} - -export function setGasEducationCarouselSeen() { - return { - type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN', - }; -} - -export function logIn() { - return { - type: LOGIN, - }; -} - -export function logOut() { - return { - type: LOGOUT, - }; -} - -export function setAppTheme(theme) { - return { - type: 'SET_APP_THEME', - payload: { theme }, - }; -} - -/** - * Temporary action to control auth flow - * - * @param {string} initialScreen - "login" or "onboarding" - * @returns - void - */ -export function checkedAuth(initialScreen) { - return { - type: 'CHECKED_AUTH', - payload: { - initialScreen, - }, - }; -} diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts new file mode 100644 index 00000000000..9071fcffd50 --- /dev/null +++ b/app/actions/user/index.ts @@ -0,0 +1,161 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { + type InterruptBiometricsAction, + type LockAppAction, + type AuthSuccessAction, + type AuthErrorAction, + type PasswordSetAction, + type PasswordUnsetAction, + type SeedphraseBackedUpAction, + type SeedphraseNotBackedUpAction, + type BackUpSeedphraseVisibleAction, + type BackUpSeedphraseNotVisibleAction, + type ProtectModalVisibleAction, + type ProtectModalNotVisibleAction, + type LoadingSetAction, + type LoadingUnsetAction, + type SetGasEducationCarouselSeenAction, + type LoginAction, + type LogoutAction, + type SetAppThemeAction, + type CheckedAuthAction, + type PersistedDataLoadedAction, + UserActionType, +} from './types'; + +export * from './types'; + +export function interruptBiometrics(): InterruptBiometricsAction { + return { + type: UserActionType.INTERRUPT_BIOMETRICS, + }; +} + +export function lockApp(): LockAppAction { + return { + type: UserActionType.LOCKED_APP, + }; +} + +export function authSuccess(bioStateMachineId?: string): AuthSuccessAction { + return { + type: UserActionType.AUTH_SUCCESS, + payload: { bioStateMachineId }, + }; +} + +export function authError(bioStateMachineId?: string): AuthErrorAction { + return { + type: UserActionType.AUTH_ERROR, + payload: { bioStateMachineId }, + }; +} + +export function passwordSet(): PasswordSetAction { + return { + type: UserActionType.PASSWORD_SET, + }; +} + +export function passwordUnset(): PasswordUnsetAction { + return { + type: UserActionType.PASSWORD_UNSET, + }; +} + +export function seedphraseBackedUp(): SeedphraseBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_BACKED_UP, + }; +} + +export function seedphraseNotBackedUp(): SeedphraseNotBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_NOT_BACKED_UP, + }; +} + +export function backUpSeedphraseAlertVisible(): BackUpSeedphraseVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_VISIBLE, + }; +} + +export function backUpSeedphraseAlertNotVisible(): BackUpSeedphraseNotVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE, + }; +} + +export function protectWalletModalVisible(): ProtectModalVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_VISIBLE, + }; +} + +export function protectWalletModalNotVisible(): ProtectModalNotVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_NOT_VISIBLE, + }; +} + +export function loadingSet(loadingMsg: string): LoadingSetAction { + return { + type: UserActionType.LOADING_SET, + loadingMsg, + }; +} + +export function loadingUnset(): LoadingUnsetAction { + return { + type: UserActionType.LOADING_UNSET, + }; +} + +export function setGasEducationCarouselSeen(): SetGasEducationCarouselSeenAction { + return { + type: UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN, + }; +} + +export function logIn(): LoginAction { + return { + type: UserActionType.LOGIN, + }; +} + +export function logOut(): LogoutAction { + return { + type: UserActionType.LOGOUT, + }; +} + +export function setAppTheme(theme: AppThemeKey): SetAppThemeAction { + return { + type: UserActionType.SET_APP_THEME, + payload: { theme }, + }; +} + +/** + * Temporary action to control auth flow + * + * @param initialScreen - "login" or "onboarding" + */ +export function checkedAuth(initialScreen: string): CheckedAuthAction { + return { + type: UserActionType.CHECKED_AUTH, + payload: { + initialScreen, + }, + }; +} + +/** + * Action to signal that persisted data has been loaded + */ +export function onPersistedDataLoaded(): PersistedDataLoadedAction { + return { + type: UserActionType.ON_PERSISTED_DATA_LOADED, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts new file mode 100644 index 00000000000..704aee6092d --- /dev/null +++ b/app/actions/user/types.ts @@ -0,0 +1,111 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { type Action } from 'redux'; + +// Action type enum +export enum UserActionType { + LOCKED_APP = 'LOCKED_APP', + AUTH_SUCCESS = 'AUTH_SUCCESS', + AUTH_ERROR = 'AUTH_ERROR', + INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + ON_PERSISTED_DATA_LOADED = 'ON_PERSISTED_DATA_LOADED', + PASSWORD_SET = 'PASSWORD_SET', + PASSWORD_UNSET = 'PASSWORD_UNSET', + SEEDPHRASE_BACKED_UP = 'SEEDPHRASE_BACKED_UP', + SEEDPHRASE_NOT_BACKED_UP = 'SEEDPHRASE_NOT_BACKED_UP', + BACK_UP_SEEDPHRASE_VISIBLE = 'BACK_UP_SEEDPHRASE_VISIBLE', + BACK_UP_SEEDPHRASE_NOT_VISIBLE = 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', + PROTECT_MODAL_VISIBLE = 'PROTECT_MODAL_VISIBLE', + PROTECT_MODAL_NOT_VISIBLE = 'PROTECT_MODAL_NOT_VISIBLE', + LOADING_SET = 'LOADING_SET', + LOADING_UNSET = 'LOADING_UNSET', + SET_GAS_EDUCATION_CAROUSEL_SEEN = 'SET_GAS_EDUCATION_CAROUSEL_SEEN', + SET_APP_THEME = 'SET_APP_THEME', + CHECKED_AUTH = 'CHECKED_AUTH', +} + +// User actions +export type LockAppAction = Action; + +export type AuthSuccessAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type AuthErrorAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type InterruptBiometricsAction = + Action; + +export type LoginAction = Action; + +export type LogoutAction = Action; + +export type PersistedDataLoadedAction = + Action; + +export type PasswordSetAction = Action; + +export type PasswordUnsetAction = Action; + +export type SeedphraseBackedUpAction = + Action; + +export type SeedphraseNotBackedUpAction = + Action; + +export type BackUpSeedphraseVisibleAction = + Action; + +export type BackUpSeedphraseNotVisibleAction = + Action; + +export type ProtectModalVisibleAction = + Action; + +export type ProtectModalNotVisibleAction = + Action; + +export type LoadingSetAction = Action & { + loadingMsg: string; +}; + +export type LoadingUnsetAction = Action; + +export type SetGasEducationCarouselSeenAction = + Action; + +export type SetAppThemeAction = Action & { + payload: { theme: AppThemeKey }; +}; + +export type CheckedAuthAction = Action & { + payload: { initialScreen: string }; +}; + +/** + * User actions union type + */ +export type UserAction = + | LockAppAction + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction + | LoginAction + | LogoutAction + | PersistedDataLoadedAction + | PasswordSetAction + | PasswordUnsetAction + | SeedphraseBackedUpAction + | SeedphraseNotBackedUpAction + | BackUpSeedphraseVisibleAction + | BackUpSeedphraseNotVisibleAction + | ProtectModalVisibleAction + | ProtectModalNotVisibleAction + | LoadingSetAction + | LoadingUnsetAction + | SetGasEducationCarouselSeenAction + | SetAppThemeAction + | CheckedAuthAction; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 7218385de28..32ec533e3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -44,6 +44,7 @@ import { getVersion } from 'react-native-device-info'; import { setCurrentBottomNavRoute, setCurrentRoute, + onNavigationReady, } from '../../../actions/navigation'; import { findRouteNameFromNavigatorState } from '../../../util/general'; import { Authentication } from '../../../core/'; @@ -880,6 +881,11 @@ const App = (props) => { } }; + /** + * Triggers when the navigation is ready + */ + const onNavigationReadyHandler = () => dispatch(onNavigationReady()); + return supressRender ? null : ( <> { @@ -905,6 +911,7 @@ const App = (props) => { const currentRoute = findRouteNameFromNavigatorState(state.routes); triggerSetCurrentRoute(currentRoute); }} + onReady={onNavigationReadyHandler} > ({ removeItem: jest.fn(), })); +jest.mock('../../../core/redux/ReduxService', () => ({ + store: { + dispatch: jest.fn(), + }, +})); + describe('useDeleteWallet', () => { test('it should provide two outputs of type function', () => { const { result } = renderHook(() => useDeleteWallet()); diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts index 9940e4c2100..ede6491cc96 100644 --- a/app/core/AppStateEventListener.test.ts +++ b/app/core/AppStateEventListener.test.ts @@ -1,23 +1,12 @@ import { AppState, AppStateStatus } from 'react-native'; -import { store } from '../store'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { AppStateEventListener } from './AppStateEventListener'; import { processAttribution } from './processAttribution'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; +import ReduxService, { ReduxStore } from './redux'; -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(), - currentState: 'active', - }, -})); - -jest.mock('../store', () => ({ - store: { - getState: jest.fn(), - }, -})); +jest.mock('./DeeplinkManager/ParseManager/extractURLParams', () => jest.fn()); jest.mock('../util/Logger', () => ({ error: jest.fn(), @@ -44,6 +33,7 @@ describe('AppStateEventListener', () => { beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); jest.useFakeTimers(); (AppState.addEventListener as jest.Mock).mockImplementation( (_, listener) => { @@ -52,7 +42,7 @@ describe('AppStateEventListener', () => { }, ); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); }); afterEach(() => { @@ -66,16 +56,14 @@ describe('AppStateEventListener', () => { ); }); - it('throws error if store is initialized more than once', () => { - expect(() => appStateManager.init(store)).toThrow( - 'store is already initialized', - ); - expect(Logger.error).toHaveBeenCalledWith( - new Error('store is already initialized'), - ); + it('does not initialize event listener more than once', () => { + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); }); it('tracks event when app becomes active and attribution data is available', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const mockAttribution = { attributionId: 'test123', utm: 'test_utm', @@ -113,6 +101,9 @@ describe('AppStateEventListener', () => { }); it('handles errors gracefully', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const testError = new Error('Test error'); (processAttribution as jest.Mock).mockImplementation(() => { throw testError; @@ -135,7 +126,7 @@ describe('AppStateEventListener', () => { }); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); appStateManager.cleanup(); expect(mockRemove).toHaveBeenCalled(); @@ -160,13 +151,22 @@ describe('AppStateEventListener', () => { }); it('should handle undefined store gracefully', () => { - appStateManager = new AppStateEventListener(); + const { processAttribution: realProcessAttribution } = jest.requireActual( + './processAttribution', + ); + (processAttribution as jest.Mock).mockImplementation( + realProcessAttribution, + ); + mockAppStateListener('active'); jest.advanceTimersByTime(2000); - expect(mockMetrics.trackEvent).not.toHaveBeenCalled(); + const missingReduxStoreError = new Error('Redux store does not exist!'); + const appStateManagerErrorMessage = + 'AppStateManager: Error processing app state change'; expect(Logger.error).toHaveBeenCalledWith( - new Error('store is not initialized'), + missingReduxStoreError, + appStateManagerErrorMessage, ); }); }); diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts index 9380aa585ea..2376c337494 100644 --- a/app/core/AppStateEventListener.ts +++ b/app/core/AppStateEventListener.ts @@ -1,36 +1,33 @@ import { AppState, AppStateStatus } from 'react-native'; -import { Store } from 'redux'; -import { RootState } from '../reducers'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; import { processAttribution } from './processAttribution'; import DevLogger from './SDKConnect/utils/DevLogger'; +import ReduxService from './redux'; export class AppStateEventListener { - private appStateSubscription: ReturnType; + private appStateSubscription: + | ReturnType + | undefined = undefined; private currentDeeplink: string | null = null; private lastAppState: AppStateStatus = AppState.currentState; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private store: Store | undefined; - constructor() { this.lastAppState = AppState.currentState; + } + + start() { + if (this.appStateSubscription) { + // Already started + return; + } this.appStateSubscription = AppState.addEventListener( 'change', this.handleAppStateChange, ); } - init(store: Store) { - if (this.store) { - Logger.error(new Error('store is already initialized')); - throw new Error('store is already initialized'); - } - this.store = store; - } - public setCurrentDeeplink(deeplink: string | null) { this.currentDeeplink = deeplink; } @@ -46,15 +43,10 @@ export class AppStateEventListener { }; private processAppStateChange = () => { - if (!this.store) { - Logger.error(new Error('store is not initialized')); - return; - } - try { const attribution = processAttribution({ currentDeeplink: this.currentDeeplink, - store: this.store, + store: ReduxService.store, }); if (attribution) { const { attributionId, utm, ...utmParams } = attribution; @@ -77,7 +69,8 @@ export class AppStateEventListener { }; public cleanup() { - this.appStateSubscription.remove(); + this.appStateSubscription?.remove(); + this.appStateSubscription = undefined; } } diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index e8be46072c4..b268825ad18 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -10,8 +10,7 @@ import AUTHENTICATION_TYPE from '../../constants/userProperties'; // eslint-disable-next-line import/no-namespace import * as Keychain from 'react-native-keychain'; import SecureKeychain from '../SecureKeychain'; -import configureMockStore from 'redux-mock-store'; -import Logger from '../../util/Logger'; +import ReduxService, { ReduxStore } from '../redux'; const storage: Record = {}; @@ -32,32 +31,11 @@ jest.mock('../../store/storage-wrapper', () => ({ })); describe('Authentication', () => { - const initialState = { - security: { - allowLoginWithRememberMe: true, - }, - }; - const mockStore = configureMockStore(); - const store = mockStore(initialState); - - beforeEach(() => { - Authentication.init(store); - }); - afterEach(() => { StorageWrapper.clearAll(); jest.restoreAllMocks(); }); - it('Does not initialize class more than once', async () => { - const spy = jest.spyOn(Logger, 'log'); - Authentication.init(store); - Authentication.init(store); - expect(spy).toHaveBeenCalledWith( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - }); - it('should return a type password', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() @@ -129,6 +107,10 @@ describe('Authentication', () => { }); it('should return a auth type for components AUTHENTICATION_TYPE.REMEMBER_ME', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ security: { allowLoginWithRememberMe: true } }), + } as unknown as ReduxStore); + SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index beba46cf7c6..ea4bf865def 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -7,7 +7,6 @@ import { PASSCODE_DISABLED, SEED_PHRASE_HINTS, } from '../../constants/storage'; -import Logger from '../../util/Logger'; import { authSuccess, authError, @@ -16,7 +15,6 @@ import { passwordSet, } from '../../actions/user'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; -import { Store } from 'redux'; import AuthenticationError from './AuthenticationError'; import { UserCredentials, BIOMETRY_TYPE } from 'react-native-keychain'; import { @@ -32,6 +30,7 @@ import StorageWrapper from '../../store/storage-wrapper'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; import { TraceName, TraceOperation, endTrace, trace } from '../../util/trace'; +import ReduxService from '../redux'; /** * Holds auth data used to determine auth configuration @@ -43,51 +42,17 @@ export interface AuthData { class AuthenticationService { private authData: AuthData = { currentAuthType: AUTHENTICATION_TYPE.UNKNOWN }; - private store: Store | undefined = undefined; - private static isInitialized = false; - - /** - * This method creates the instance of the authentication class - * @param {Store} store - A redux function that will dispatch global state actions - */ - init(store: Store) { - if (!AuthenticationService.isInitialized) { - AuthenticationService.isInitialized = true; - this.store = store; - } else { - Logger.log( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - } - } private dispatchLogin(): void { - if (this.store) { - this.store.dispatch(logIn()); - } else { - Logger.log( - 'Attempted to dispatch logIn action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(logIn()); } private dispatchPasswordSet(): void { - if (this.store) { - this.store.dispatch(passwordSet()); - } else { - Logger.log( - 'Attempted to dispatch passwordSet action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(passwordSet()); } private dispatchLogout(): void { - if (this.store) { - this.store.dispatch(logOut()); - } else - Logger.log( - 'Attempted to dispatch logOut action but dispatch was not initialized', - ); + ReduxService.store.dispatch(logOut()); } /** @@ -307,7 +272,7 @@ class AuthenticationService { }; } else if ( rememberMe && - this.store?.getState().security.allowLoginWithRememberMe + ReduxService.store.getState().security.allowLoginWithRememberMe ) { return { currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, @@ -460,12 +425,12 @@ class AuthenticationService { endTrace({ name: TraceName.VaultCreation }); this.dispatchLogin(); - this.store?.dispatch(authSuccess(bioStateMachineId)); + ReduxService.store.dispatch(authSuccess(bioStateMachineId)); this.dispatchPasswordSet(); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { - this.store?.dispatch(authError(bioStateMachineId)); + ReduxService.store.dispatch(authError(bioStateMachineId)); !disableAutoLogout && this.lockApp({ reset: false }); throw new AuthenticationError( (e as Error).message, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 294baf2b96f..4e21df41a30 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -154,9 +154,9 @@ import { AccountsControllerSelectedAccountChangeEvent, AccountsControllerAccountAddedEvent, AccountsControllerAccountRenamedEvent, -} from './controllers/accounts/constants'; +} from './controllers/AccountsController/constants'; import { AccountsControllerMessenger } from '@metamask/accounts-controller'; -import { createAccountsController } from './controllers/accounts/utils'; +import { createAccountsController } from './controllers/AccountsController/utils'; import { createRemoteFeatureFlagController } from './controllers/RemoteFeatureFlagController'; import { captureException } from '@sentry/react-native'; import { lowerCase } from 'lodash'; diff --git a/app/core/Engine/controllers/accounts/constants.ts b/app/core/Engine/controllers/AccountsController/constants.ts similarity index 100% rename from app/core/Engine/controllers/accounts/constants.ts rename to app/core/Engine/controllers/AccountsController/constants.ts diff --git a/app/core/Engine/controllers/accounts/utils.test.ts b/app/core/Engine/controllers/AccountsController/utils.test.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.test.ts rename to app/core/Engine/controllers/AccountsController/utils.test.ts diff --git a/app/core/Engine/controllers/accounts/utils.ts b/app/core/Engine/controllers/AccountsController/utils.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.ts rename to app/core/Engine/controllers/AccountsController/utils.ts diff --git a/app/core/Engine/controllers/accounts/README.md b/app/core/Engine/controllers/accounts/README.md deleted file mode 100644 index 24ea735bd59..00000000000 --- a/app/core/Engine/controllers/accounts/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Controllers owned by the Accounts team - -This folder contains controllers that are owned by the Accounts team. diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 20ee32ab5bf..99bc238b2f5 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -1,39 +1,40 @@ -import EngineService from './EngineService'; +import { EngineService } from './EngineService'; +import ReduxService, { type ReduxStore } from '../redux'; import Engine from '../Engine'; -import { store } from '../../store'; +import { type KeyringControllerState } from '@metamask/keyring-controller'; +import NavigationService from '../NavigationService'; +import Logger from '../../util/Logger'; +import Routes from '../../constants/navigation/Routes'; -jest.mock('../../util/test/network-store.js', () => jest.fn()); -jest.mock('../../store', () => ({ - store: { - getState: jest.fn(() => ({ - engine: { - backgroundState: {}, - }, - })), - dispatch: jest.fn(), +// Mock NavigationService +jest.mock('../NavigationService', () => ({ + navigation: { + reset: jest.fn(), }, })); +// Mock Logger +jest.mock('../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../BackupVault', () => ({ + getVaultFromBackup: () => ({ success: true, vault: 'fake_vault' }), +})); + +jest.mock('../../util/test/network-store.js', () => jest.fn()); + +// Unmock global Engine +jest.unmock('../Engine'); + jest.mock('../Engine', () => { // Do not need to mock entire Engine. Only need subset of data for testing purposes. // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any let instance: any; - return { - get context() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.context; - }, - get controllerMessenger() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.controllerMessenger; - }, - destroyEngine: jest.fn(), - init: jest.fn((_, keyringState) => { + + const mockEngine = { + init: (_: unknown, keyringState: KeyringControllerState) => { instance = { controllerMessenger: { subscribe: jest.fn(), @@ -77,18 +78,68 @@ jest.mock('../Engine', () => { }, }; return instance; + }, + get context() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.context; + }, + get controllerMessenger() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.controllerMessenger; + }, + destroyEngine: jest.fn(async () => { + instance = null; }), }; + + return { + __esModule: true, + default: mockEngine, + }; }); describe('EngineService', () => { - EngineService.initalizeEngine(store); + let engineService: EngineService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ engine: { backgroundState: {} } }), + } as unknown as ReduxStore); + + engineService = new EngineService(); + }); + it('should have Engine initialized', () => { + engineService.start(); expect(Engine.context).toBeDefined(); }); + it('should have recovered vault on redux store ', async () => { - const { success } = await EngineService.initializeVaultFromBackup(); + engineService.start(); + const { success } = await engineService.initializeVaultFromBackup(); expect(success).toBeTruthy(); expect(Engine.context.KeyringController.state.vault).toBeDefined(); }); + + it('should navigate to vault recovery if Engine fails to initialize', () => { + jest.spyOn(Engine, 'init').mockImplementation(() => { + throw new Error('Failed to initialize Engine'); + }); + engineService.start(); + // Logs error to Sentry + expect(Logger.error).toHaveBeenCalledWith( + new Error('Failed to initialize Engine'), + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigates to vault recovery + expect(NavigationService.navigation?.reset).toHaveBeenCalledWith({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + }); }); diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index bedb3e97534..6ba3e827cb9 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -1,7 +1,6 @@ import UntypedEngine from '../Engine'; import AppConstants from '../AppConstants'; import { getVaultFromBackup } from '../BackupVault'; -import { store as importedStore } from '../../store'; import Logger from '../../util/Logger'; import { NO_VAULT_IN_BACKUP_ERROR, @@ -10,6 +9,9 @@ import { import { getTraceTags } from '../../util/sentry/tags'; import { trace, endTrace, TraceName, TraceOperation } from '../../util/trace'; import getUIStartupSpan from '../Performance/UIStartup'; +import ReduxService from '../redux'; +import NavigationService from '../NavigationService'; +import Routes from '../../constants/navigation/Routes'; interface InitializeEngineResult { success: boolean; @@ -18,37 +20,57 @@ interface InitializeEngineResult { const UPDATE_BG_STATE_KEY = 'UPDATE_BG_STATE'; const INIT_BG_STATE_KEY = 'INIT_BG_STATE'; -class EngineService { +export class EngineService { private engineInitialized = false; /** - * Initializer for the EngineService + * Starts the Engine and subscribes to the controller state changes * - * @param store - Redux store + * EngineService.start() with SES/lockdown: + * Requires ethjs nested patches (lib->src) + * - ethjs/ethjs-query + * - ethjs/ethjs-contract + * Otherwise causing the following errors: + * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' + * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: + * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' + * - JSC: Attempted to assign to readonly property + * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js + * - V8: TypeError: _$$_REQUIRE(...) is not a constructor + * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') + * - V8: SES_UNHANDLED_REJECTION */ - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - initalizeEngine = (store: any) => { + start = () => { + const reduxState = ReduxService.store.getState(); trace({ name: TraceName.EngineInitialization, op: TraceOperation.EngineInitialization, parentContext: getUIStartupSpan(), - tags: getTraceTags(store.getState()), + tags: getTraceTags(reduxState), }); - const reduxState = store.getState?.(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const Engine = UntypedEngine as any; - Engine.init(state); - this.updateControllers(store, Engine); + try { + Engine.init(state); + this.updateControllers(Engine); + } catch (error) { + Logger.error( + error as Error, + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigate to vault recovery + NavigationService.navigation?.reset({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + } endTrace({ name: TraceName.EngineInitialization }); }; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - private updateControllers = (store: any, engine: any) => { + private updateControllers = (engine: any) => { if (!engine.context) { Logger.error( new Error( @@ -179,7 +201,7 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); } - store.dispatch({ type: INIT_BG_STATE_KEY }); + ReduxService.store.dispatch({ type: INIT_BG_STATE_KEY }); this.engineInitialized = true; }, () => !this.engineInitialized, @@ -191,7 +213,10 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for UPDATE_BG_STATE_KEY'); } - store.dispatch({ type: UPDATE_BG_STATE_KEY, payload: { key: name } }); + ReduxService.store.dispatch({ + type: UPDATE_BG_STATE_KEY, + payload: { key: name }, + }); }; engine.controllerMessenger.subscribe(key, update_bg_state_cb); }); @@ -208,7 +233,7 @@ class EngineService { */ async initializeVaultFromBackup(): Promise { const keyringState = await getVaultFromBackup(); - const reduxState = importedStore.getState?.(); + const reduxState = ReduxService.store.getState(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -223,7 +248,7 @@ class EngineService { }; const instance = Engine.init(state, newKeyringState); if (instance) { - this.updateControllers(importedStore, instance); + this.updateControllers(instance); // this is a hack to give the engine time to reinitialize await new Promise((resolve) => setTimeout(resolve, 2000)); return { diff --git a/app/core/EngineService/index.ts b/app/core/EngineService/index.ts index 9bed417ddf2..dd9072d50ea 100644 --- a/app/core/EngineService/index.ts +++ b/app/core/EngineService/index.ts @@ -1,2 +1 @@ -import EngineService from './EngineService'; -export default EngineService; +export { default } from './EngineService'; diff --git a/app/core/LockManagerService/index.test.ts b/app/core/LockManagerService/index.test.ts index a562ab57efe..4924e5d479d 100644 --- a/app/core/LockManagerService/index.test.ts +++ b/app/core/LockManagerService/index.test.ts @@ -1,7 +1,8 @@ -import LockManagerService from '.'; -import { AppState } from 'react-native'; -import configureMockStore from 'redux-mock-store'; +import { LockManagerService } from '.'; +import { AppState, AppStateStatus } from 'react-native'; import { interruptBiometrics, lockApp } from '../../actions/user'; +import Logger from '../../util/Logger'; +import ReduxService, { type ReduxStore } from '../redux'; jest.mock('../Engine', () => ({ context: { @@ -10,133 +11,148 @@ jest.mock('../Engine', () => ({ }, }, })); + const mockSetTimeout = jest.fn(); + jest.mock('react-native-background-timer', () => ({ setTimeout: () => mockSetTimeout(), })); + jest.mock('../SecureKeychain', () => ({ getInstance: () => ({ isAuthenticating: false, }), })); -const initialState = { - settings: { - lockTime: 0, - }, -}; -const mockStore = configureMockStore(); -const defaultStore = mockStore(initialState); - -describe('startListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing when store is undefined.', async () => { - LockManagerService.startListening(); - expect(addEventListener).not.toBeCalled(); - }); - - it('should do nothing when app state listener is already subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - }); - - it('should add event listener when store is defined and listener is not yet subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalled(); - }); -}); +jest.mock('../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); -describe('stopListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); +describe('LockManagerService', () => { + let lockManagerService: LockManagerService; + let mockAppStateListener: (state: AppStateStatus) => void; - afterEach(() => { - LockManagerService.stopListening(); + beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); + jest.useFakeTimers(); + (AppState.addEventListener as jest.Mock).mockImplementation( + (_, listener) => { + mockAppStateListener = listener; + return { remove: jest.fn() }; + }, + ); + lockManagerService = new LockManagerService(); }); - it('should remove app state listener.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.stopListening(); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(2); - }); -}); - -describe('handleAppStateChange', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - const defaultDispatch = jest.spyOn(defaultStore, 'dispatch'); - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing if lockTime is -1 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: -1 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing if lockTime is 0 while going inactive.', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing while lockTime is 0 while going from inactive to active', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - appStateTrigger('active'); - expect(defaultDispatch).not.toBeCalled(); + lockManagerService.stopListening(); + jest.useRealTimers(); }); - it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(dispatch).toBeCalledWith(interruptBiometrics()); + describe('startListening', () => { + it('should do nothing when app state listener is already subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + expect(Logger.log).toHaveBeenCalledWith( + 'Already subscribed to app state listener.', + ); + }); + + it('should add event listener when it is not yet subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalled(); + }); }); - it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(await dispatch).toBeCalledWith(lockApp()); + describe('stopListening', () => { + it('should remove app state listener.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.stopListening(); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(2); + }); }); - it('should set background timer when lockTimer is non-zero while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(mockSetTimeout).toBeCalled(); + describe('handleAppStateChange', () => { + it('should throw an error if store is undefined.', async () => { + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + 'LockManagerService: Error handling app state change', + ); + }); + + it('should do nothing if lockTime is -1 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: -1 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing if lockTime is 0 while going inactive.', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing while lockTime is 0 while going from inactive to active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockDispatch).toHaveBeenCalledWith(interruptBiometrics()); + }); + + it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(await mockDispatch).toHaveBeenCalledWith(lockApp()); + }); + + it('should set background timer when lockTimer is non-zero while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockSetTimeout).toHaveBeenCalled(); + }); }); }); diff --git a/app/core/LockManagerService/index.ts b/app/core/LockManagerService/index.ts index a5f0b346827..0fba5c0978a 100644 --- a/app/core/LockManagerService/index.ts +++ b/app/core/LockManagerService/index.ts @@ -8,26 +8,19 @@ import BackgroundTimer from 'react-native-background-timer'; import Engine from '../Engine'; import Logger from '../../util/Logger'; import { lockApp, interruptBiometrics } from '../../actions/user'; -import { Store } from 'redux'; +import ReduxService from '../redux'; -class LockManagerService { +export class LockManagerService { #appState?: AppStateStatus; #appStateListener?: NativeEventSubscription; #lockTimer?: number; - #store?: Store; - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - init = (store: any) => { - this.#store = store; - }; #lockApp = async () => { if (!SecureKeychain.getInstance().isAuthenticating) { const { KeyringController } = Engine.context; try { await KeyringController.setLocked(); - this.#store?.dispatch(lockApp()); + ReduxService.store.dispatch(lockApp()); } catch (error) { Logger.log('Failed to lock KeyringController', error); } @@ -47,55 +40,58 @@ class LockManagerService { #handleAppStateChange = async (nextAppState: AppStateStatus) => { // Don't auto-lock. - const lockTime = this.#store?.getState().settings.lockTime; - if ( - lockTime === -1 || // Lock timer isn't set. - nextAppState === 'inactive' || // Ignore inactive state. - (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. - ) { - this.#appState = nextAppState; - return; - } + try { + const lockTime = ReduxService.store.getState().settings.lockTime; + if ( + lockTime === -1 || // Lock timer isn't set. + nextAppState === 'inactive' || // Ignore inactive state. + (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. + ) { + this.#appState = nextAppState; + return; + } - // EDGE CASE - // Handles interruptions in the middle of authentication while lock timer is a non-zero value - // This is most likely called when the background timer fails to be called while backgrounding the app - if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { - this.#store?.dispatch(interruptBiometrics()); - } + // EDGE CASE + // Handles interruptions in the middle of authentication while lock timer is a non-zero value + // This is most likely called when the background timer fails to be called while backgrounding the app + if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { + ReduxService.store.dispatch(interruptBiometrics()); + } - // Handle lock logic on background. - if (nextAppState === 'background') { - if (lockTime === 0) { - this.#lockApp(); - } else { - // Autolock after some time. + // Handle lock logic on background. + if (nextAppState === 'background') { + if (lockTime === 0) { + this.#lockApp(); + } else { + // Autolock after some time. + this.#clearBackgroundTimer(); + this.#lockTimer = BackgroundTimer.setTimeout(() => { + if (this.#lockTimer) { + this.#lockApp(); + } + }, lockTime); + } + } + + // App has foregrounded from background. + // Clear background timer for safe measure. + if (nextAppState === 'active') { this.#clearBackgroundTimer(); - this.#lockTimer = BackgroundTimer.setTimeout(() => { - if (this.#lockTimer) { - this.#lockApp(); - } - }, lockTime); } - } - // App has foregrounded from background. - // Clear background timer for safe measure. - if (nextAppState === 'active') { - this.#clearBackgroundTimer(); + this.#appState = nextAppState; + } catch (error) { + Logger.error( + error as Error, + 'LockManagerService: Error handling app state change', + ); } - - this.#appState = nextAppState; }; /** * Listen to AppState events to control lock state. */ startListening = () => { - if (!this.#store) { - Logger.log('Failed to start listener since store is undefined.'); - return; - } if (this.#appStateListener) { Logger.log('Already subscribed to app state listener.'); return; diff --git a/app/core/redux/ReduxService.test.ts b/app/core/redux/ReduxService.test.ts new file mode 100644 index 00000000000..7b14a872c07 --- /dev/null +++ b/app/core/redux/ReduxService.test.ts @@ -0,0 +1,67 @@ +import ReduxService from './ReduxService'; +import Logger from '../../util/Logger'; +import type { ReduxStore } from './types'; + +describe('ReduxService', () => { + let mockStore: ReduxStore; + + beforeEach(() => { + // Reset any internal state + jest.clearAllMocks(); + + // Create a mock store + mockStore = { + dispatch: jest.fn(), + getState: jest.fn(), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + } as unknown as ReduxStore; + + // Spy on Logger + jest.spyOn(Logger, 'error'); + }); + + describe('store getter', () => { + it('should throw error if store does not exist', () => { + expect(() => ReduxService.store).toThrow('Redux store does not exist!'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + ); + }); + + it('should return store if it exists', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + }); + + describe('store setter', () => { + it('should throw error if store is invalid', () => { + const invalidStore = {} as ReduxStore; + + expect(() => { + ReduxService.store = invalidStore; + }).toThrow('Redux store is not a valid store!'); + + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store is not a valid store!'), + ); + }); + + it('should set store if valid', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + + it('should validate store has required methods', () => { + const incompleteStore = { + dispatch: jest.fn(), + // missing getState + } as unknown as ReduxStore; + + expect(() => { + ReduxService.store = incompleteStore; + }).toThrow('Redux store is not a valid store!'); + }); + }); +}); diff --git a/app/core/redux/ReduxService.ts b/app/core/redux/ReduxService.ts new file mode 100644 index 00000000000..7f2d9f75fa6 --- /dev/null +++ b/app/core/redux/ReduxService.ts @@ -0,0 +1,49 @@ +import Logger from '../../util/Logger'; +import { ReduxStore } from './types'; + +/** + * ReduxService class that manages the Redux store + */ +class ReduxService { + static #reduxStore: ReduxStore; + + static #assertReduxStoreType(store: ReduxStore) { + if ( + typeof store.dispatch !== 'function' || + typeof store.getState !== 'function' + ) { + const error = new Error('Redux store is not a valid store!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + static #assertReduxStoreExists() { + if (!this.#reduxStore) { + const error = new Error('Redux store does not exist!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + /** + * Set the store in the Redux class + * @param store + */ + static set store(store: ReduxStore) { + this.#assertReduxStoreType(store); + this.#reduxStore = store; + } + + /** + * Get the store from the Redux class + */ + static get store() { + this.#assertReduxStoreExists(); + return this.#reduxStore; + } +} + +export default ReduxService; diff --git a/app/core/redux/index.ts b/app/core/redux/index.ts new file mode 100644 index 00000000000..d3b1d8b7418 --- /dev/null +++ b/app/core/redux/index.ts @@ -0,0 +1,3 @@ +export { default } from './ReduxService'; + +export * from './types'; diff --git a/app/core/redux/types.ts b/app/core/redux/types.ts new file mode 100644 index 00000000000..09fff70ce7e --- /dev/null +++ b/app/core/redux/types.ts @@ -0,0 +1,8 @@ +import { AnyAction, Store } from 'redux'; +import { RootState } from '../../reducers'; + +/** + * Redux store type + * TODO: Replace AnyAction with union type of all actions + */ +export type ReduxStore = Store; diff --git a/app/reducers/index.ts b/app/reducers/index.ts index ee998f4724f..990bc742032 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -7,7 +7,7 @@ import settingsReducer from './settings'; import alertReducer from './alert'; import transactionReducer from './transaction'; import legalNoticesReducer from './legalNotices'; -import userReducer, { IUserReducer } from './user'; +import userReducer, { UserState } from './user'; import wizardReducer from './wizard'; import onboardingReducer from './onboarding'; import fiatOrders from './fiatOrders'; @@ -16,7 +16,7 @@ import signatureRequestReducer from './signatureRequest'; import notificationReducer from './notification'; import infuraAvailabilityReducer from './infuraAvailability'; import collectiblesReducer from './collectibles'; -import navigationReducer from './navigation'; +import navigationReducer, { NavigationState } from './navigation'; import networkOnboardReducer from './networkSelector'; import securityReducer, { SecurityState } from './security'; import { combineReducers, Reducer } from 'redux'; @@ -81,7 +81,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any transaction: any; - user: IUserReducer; + user: UserState; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any wizard: any; @@ -98,10 +98,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any infuraAvailability: any; - // The navigation reducer is TypeScript but not yet a valid reducer - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigation: any; + navigation: NavigationState; // The networkOnboarded reducer is TypeScript but not yet a valid reducer // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/reducers/navigation/index.ts b/app/reducers/navigation/index.ts index 9078531b0cb..4cdf8a0558f 100644 --- a/app/reducers/navigation/index.ts +++ b/app/reducers/navigation/index.ts @@ -1,32 +1,36 @@ -/** - * Constants - */ -export const SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE'; -export const SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_TAB_BAR_ROUTE'; +import { + type NavigationAction, + NavigationActionType, +} from '../../actions/navigation/types'; +import { NavigationState } from './types'; + +export * from './types'; + +export * from './selectors'; /** - * Reducers + * Initial navigation state */ -interface InitialState { - currentRoute: string; - currentBottomNavRoute: string; -} - -const initialState: InitialState = { +export const initialNavigationState: NavigationState = { currentRoute: 'WalletView', currentBottomNavRoute: 'Wallet', }; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const navigationReducer = (state = initialState, action: any = {}) => { +/** + * Navigation reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ +const navigationReducer = ( + state: NavigationState = initialNavigationState, + action: NavigationAction, +): NavigationState => { switch (action.type) { - case SET_CURRENT_ROUTE: + case NavigationActionType.SET_CURRENT_ROUTE: return { ...state, currentRoute: action.payload.route, }; - case SET_CURRENT_BOTTOM_NAV_ROUTE: + case NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE: return { ...state, currentBottomNavRoute: action.payload.route, diff --git a/app/reducers/navigation/selectors.ts b/app/reducers/navigation/selectors.ts new file mode 100644 index 00000000000..a85aaa1cac0 --- /dev/null +++ b/app/reducers/navigation/selectors.ts @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; +import { RootState } from '..'; + +/** + * Selects the navigation state + */ +export const selectNavigationState = (state: RootState) => state.navigation; + +/** + * Selects the current route + */ +export const selectCurrentRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentRoute, +); + +/** + * Selects the current bottom nav route + */ +export const selectCurrentBottomNavRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentBottomNavRoute, +); diff --git a/app/reducers/navigation/types.ts b/app/reducers/navigation/types.ts new file mode 100644 index 00000000000..b5f22b005e6 --- /dev/null +++ b/app/reducers/navigation/types.ts @@ -0,0 +1,8 @@ +/** + * Navigation state + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationState = { + currentRoute: string; + currentBottomNavRoute: string; +}; diff --git a/app/reducers/user/index.ts b/app/reducers/user/index.ts index a942f0eb88d..5941561d6ea 100644 --- a/app/reducers/user/index.ts +++ b/app/reducers/user/index.ts @@ -1,21 +1,15 @@ +import { UserAction, UserActionType } from '../../actions/user/types'; import { AppThemeKey } from '../../util/theme/models'; +import { UserState } from './types'; -export interface IUserReducer { - loadingMsg: string; - loadingSet: boolean; - passwordSet: boolean; - seedphraseBackedUp: boolean; - backUpSeedphraseVisible: boolean; - protectWalletModalVisible: boolean; - gasEducationCarouselSeen: boolean; - userLoggedIn: boolean; - isAuthChecked: boolean; - initialScreen: string; - appTheme: AppThemeKey; - ambiguousAddressEntries: Record; -} +export * from './types'; -export const userInitialState = { +export * from './selectors'; + +/** + * Initial user state + */ +export const userInitialState: UserState = { loadingMsg: '', loadingSet: false, passwordSet: false, @@ -30,83 +24,69 @@ export const userInitialState = { ambiguousAddressEntries: {}, }; -// Define action types -type UserAction = - | { type: 'LOGIN' } - | { type: 'LOGOUT' } - | { type: 'LOADING_SET'; loadingMsg: string } - | { type: 'LOADING_UNSET' } - | { type: 'PASSWORD_SET' } - | { type: 'PASSWORD_UNSET' } - | { type: 'SEEDPHRASE_NOT_BACKED_UP' } - | { type: 'SEEDPHRASE_BACKED_UP' } - | { type: 'BACK_UP_SEEDPHRASE_VISIBLE' } - | { type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE' } - | { type: 'PROTECT_MODAL_VISIBLE' } - | { type: 'PROTECT_MODAL_NOT_VISIBLE' } - | { type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN' } - | { type: 'SET_APP_THEME'; payload: { theme: AppThemeKey } }; - +/** + * User reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ const userReducer = ( - // eslint-disable-next-line @typescript-eslint/default-param-last - state: IUserReducer = userInitialState, + state: UserState = userInitialState, action: UserAction, -) => { +): UserState => { switch (action.type) { - case 'LOGIN': + case UserActionType.LOGIN: return { ...state, userLoggedIn: true, }; - case 'LOGOUT': + case UserActionType.LOGOUT: return { ...state, userLoggedIn: false, }; - case 'LOADING_SET': + case UserActionType.LOADING_SET: return { ...state, loadingSet: true, loadingMsg: action.loadingMsg, }; - case 'LOADING_UNSET': + case UserActionType.LOADING_UNSET: return { ...state, loadingSet: false, }; - case 'PASSWORD_SET': + case UserActionType.PASSWORD_SET: return { ...state, passwordSet: true, }; - case 'PASSWORD_UNSET': + case UserActionType.PASSWORD_UNSET: return { ...state, passwordSet: false, }; - case 'SEEDPHRASE_NOT_BACKED_UP': + case UserActionType.SEEDPHRASE_NOT_BACKED_UP: return { ...state, seedphraseBackedUp: false, backUpSeedphraseVisible: true, }; - case 'SEEDPHRASE_BACKED_UP': + case UserActionType.SEEDPHRASE_BACKED_UP: return { ...state, seedphraseBackedUp: true, backUpSeedphraseVisible: false, }; - case 'BACK_UP_SEEDPHRASE_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_VISIBLE: return { ...state, backUpSeedphraseVisible: true, }; - case 'BACK_UP_SEEDPHRASE_NOT_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE: return { ...state, backUpSeedphraseVisible: false, }; - case 'PROTECT_MODAL_VISIBLE': + case UserActionType.PROTECT_MODAL_VISIBLE: if (!state.seedphraseBackedUp) { return { ...state, @@ -114,17 +94,17 @@ const userReducer = ( }; } return state; - case 'PROTECT_MODAL_NOT_VISIBLE': + case UserActionType.PROTECT_MODAL_NOT_VISIBLE: return { ...state, protectWalletModalVisible: false, }; - case 'SET_GAS_EDUCATION_CAROUSEL_SEEN': + case UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN: return { ...state, gasEducationCarouselSeen: true, }; - case 'SET_APP_THEME': + case UserActionType.SET_APP_THEME: return { ...state, appTheme: action.payload.theme, diff --git a/app/reducers/user/selectors.ts b/app/reducers/user/selectors.ts new file mode 100644 index 00000000000..2341b19cad8 --- /dev/null +++ b/app/reducers/user/selectors.ts @@ -0,0 +1,6 @@ +import { RootState } from '..'; + +/** + * Selects the user state + */ +export const selectUserState = (state: RootState) => state.user; diff --git a/app/reducers/user/types.ts b/app/reducers/user/types.ts new file mode 100644 index 00000000000..4080aebc5ee --- /dev/null +++ b/app/reducers/user/types.ts @@ -0,0 +1,19 @@ +import { AppThemeKey } from '../../util/theme/models'; + +/** + * User state + */ +export interface UserState { + loadingMsg: string; + loadingSet: boolean; + passwordSet: boolean; + seedphraseBackedUp: boolean; + backUpSeedphraseVisible: boolean; + protectWalletModalVisible: boolean; + gasEducationCarouselSeen: boolean; + userLoggedIn: boolean; + isAuthChecked: boolean; + initialScreen: string; + appTheme: AppThemeKey; + ambiguousAddressEntries: Record; +} diff --git a/app/store/index.ts b/app/store/index.ts index 5f74eb552d9..e158b1e5f81 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -4,9 +4,6 @@ import { persistStore, persistReducer } from 'redux-persist'; import createSagaMiddleware from 'redux-saga'; import { rootSaga } from './sagas'; import rootReducer, { RootState } from '../reducers'; -import EngineService from '../core/EngineService'; -import { Authentication } from '../core'; -import LockManagerService from '../core/LockManagerService'; import ReadOnlyNetworkStore from '../util/test/network-store'; import { isE2E } from '../util/test/utils'; import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; @@ -14,8 +11,9 @@ import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; import thunk from 'redux-thunk'; import persistConfig from './persistConfig'; -import { AppStateEventProcessor } from '../core/AppStateEventListener'; import getUIStartupSpan from '../core/Performance/UIStartup'; +import ReduxService from '../core/redux'; +import { onPersistedDataLoaded } from '../actions/user'; // TODO: Improve type safety by using real Action types instead of `any` // TODO: Replace "any" with type @@ -59,6 +57,8 @@ const createStoreAndPersistor = async () => { middleware: middlewares, preloadedState: initialState, }); + // Set the store in the Redux class + ReduxService.store = store; sagaMiddleware.run(rootSaga); @@ -66,34 +66,9 @@ const createStoreAndPersistor = async () => { * Initialize services after persist is completed */ const onPersistComplete = () => { - /** - * EngineService.initalizeEngine(store) with SES/lockdown: - * Requires ethjs nested patches (lib->src) - * - ethjs/ethjs-query - * - ethjs/ethjs-contract - * Otherwise causing the following errors: - * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' - * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: - * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' - * - JSC: Attempted to assign to readonly property - * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js - * - V8: TypeError: _$$_REQUIRE(...) is not a constructor - * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') - * - V8: SES_UNHANDLED_REJECTION - */ - - store.dispatch({ - type: 'TOGGLE_BASIC_FUNCTIONALITY', - basicFunctionalityEnabled: - store.getState().settings.basicFunctionalityEnabled, - }); - - EngineService.initalizeEngine(store); - - Authentication.init(store); - AppStateEventProcessor.init(store); - LockManagerService.init(store); endTrace({ name: TraceName.StoreInit }); + // Signal that persisted data has been loaded + store.dispatch(onPersistedDataLoaded()); }; persistor = persistStore(store, null, onPersistComplete); diff --git a/app/store/persistConfig.ts b/app/store/persistConfig.ts index 0aa71c0ccad..5b8b4358134 100644 --- a/app/store/persistConfig.ts +++ b/app/store/persistConfig.ts @@ -6,7 +6,7 @@ import { RootState } from '../reducers'; import { migrations, version } from './migrations'; import Logger from '../util/Logger'; import Device from '../util/device'; -import { IUserReducer } from '../reducers/user'; +import { UserState } from '../reducers/user'; const TIMEOUT = 40000; @@ -96,7 +96,7 @@ const persistTransform = createTransform( ); const persistUserTransform = createTransform( - (inboundState: IUserReducer) => { + (inboundState: UserState) => { const { initialScreen, isAuthChecked, ...state } = inboundState; // Reconstruct data to persist return state; diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 32afb2b55c7..b7b0724a993 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -1,15 +1,14 @@ -import { fork, take, cancel, put, call } from 'redux-saga/effects'; +import { fork, take, cancel, put, call, all } from 'redux-saga/effects'; import NavigationService from '../../core/NavigationService'; import Routes from '../../constants/navigation/Routes'; import { - LOCKED_APP, - AUTH_SUCCESS, - AUTH_ERROR, + AuthSuccessAction, + AuthErrorAction, + InterruptBiometricsAction, lockApp, - INTERRUPT_BIOMETRICS, - LOGOUT, - LOGIN, + UserActionType, } from '../../actions/user'; +import { NavigationActionType } from '../../actions/navigation'; import { Task } from 'redux-saga'; import Engine from '../../core/Engine'; import Logger from '../../util/Logger'; @@ -18,11 +17,13 @@ import { overrideXMLHttpRequest, restoreXMLHttpRequest, } from './xmlHttpRequestOverride'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; export function* appLockStateMachine() { let biometricsListenerTask: Task | undefined; while (true) { - yield take(LOCKED_APP); + yield take(UserActionType.LOCKED_APP); if (biometricsListenerTask) { yield cancel(biometricsListenerTask); } @@ -45,11 +46,11 @@ export function* appLockStateMachine() { export function* authStateMachine() { // Start when the user is logged in. while (true) { - yield take(LOGIN); + yield take(UserActionType.LOGIN); const appLockStateMachineTask: Task = yield fork(appLockStateMachine); LockManagerService.startListening(); // Listen to app lock behavior. - yield take(LOGOUT); + yield take(UserActionType.LOGOUT); LockManagerService.stopListening(); // Cancels appLockStateMachineTask, which also cancels nested sagas once logged out. yield cancel(appLockStateMachineTask); @@ -78,32 +79,32 @@ export function* biometricsStateMachine(originalBioStateMachineId: string) { // Handle next three possible states. let shouldHandleAction = false; let action: - | { - type: - | typeof AUTH_SUCCESS - | typeof AUTH_ERROR - | typeof INTERRUPT_BIOMETRICS; - payload?: { bioStateMachineId: string }; - } + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction | undefined; // Only continue on INTERRUPT_BIOMETRICS action or when actions originated from corresponding state machine. while (!shouldHandleAction) { - action = yield take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]); + action = yield take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]); if ( - action?.type === INTERRUPT_BIOMETRICS || + action?.type === UserActionType.INTERRUPT_BIOMETRICS || action?.payload?.bioStateMachineId === originalBioStateMachineId ) { shouldHandleAction = true; } } - if (action?.type === INTERRUPT_BIOMETRICS) { + if (action?.type === UserActionType.INTERRUPT_BIOMETRICS) { // Biometrics was most likely interrupted during authentication with a non-zero lock timer. yield fork(lockKeyringAndApp); - } else if (action?.type === AUTH_ERROR) { + } else if (action?.type === UserActionType.AUTH_ERROR) { // Authentication service will automatically log out. - } else if (action?.type === AUTH_SUCCESS) { + } else if (action?.type === UserActionType.AUTH_SUCCESS) { // Authentication successful. Navigate to wallet. NavigationService.navigation?.navigate(Routes.ONBOARDING.HOME_NAV); } @@ -124,8 +125,24 @@ export function* basicFunctionalityToggle() { } } +/** + * Handles initializing app services on start up + */ +export function* startAppServices() { + // Wait for persisted data to be loaded and navigation to be ready + yield all([ + take(UserActionType.ON_PERSISTED_DATA_LOADED), + take(NavigationActionType.ON_NAVIGATION_READY), + ]); + // Start services + EngineService.start(); + AppStateEventProcessor.start(); + // TODO: Track a property in redux to gate keep the app until services are initialized +} + // Main generator function that initializes other sagas in parallel. export function* rootSaga() { + yield fork(startAppServices); yield fork(authStateMachine); yield fork(basicFunctionalityToggle); } diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index c9830d7f8d4..70ffa35e276 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -1,12 +1,8 @@ import { Action } from 'redux'; import { take, fork, cancel } from 'redux-saga/effects'; +import { expectSaga } from 'redux-saga-test-plan'; import { - AUTH_ERROR, - AUTH_SUCCESS, - INTERRUPT_BIOMETRICS, - LOGIN, - LOCKED_APP, - LOGOUT, + UserActionType, authError, authSuccess, interruptBiometrics, @@ -17,10 +13,16 @@ import { authStateMachine, appLockStateMachine, lockKeyringAndApp, + startAppServices, } from './'; +import { NavigationActionType } from '../../actions/navigation'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; const mockBioStateMachineId = '123'; + const mockNavigate = jest.fn(); + jest.mock('../../core/NavigationService', () => ({ navigation: { // TODO: Replace "any" with type @@ -31,6 +33,17 @@ jest.mock('../../core/NavigationService', () => ({ }, })); +// Mock the services +jest.mock('../../core/EngineService', () => ({ + start: jest.fn(), +})); + +jest.mock('../../core/AppStateEventListener', () => ({ + AppStateEventProcessor: { + start: jest.fn(), + }, +})); + describe('authStateMachine', () => { beforeEach(() => { mockNavigate.mockClear(); @@ -38,7 +51,7 @@ describe('authStateMachine', () => { it('should fork appLockStateMachine when logged in', async () => { const generator = authStateMachine(); - expect(generator.next().value).toEqual(take(LOGIN)); + expect(generator.next().value).toEqual(take(UserActionType.LOGIN)); expect(generator.next().value).toEqual(fork(appLockStateMachine)); }); @@ -48,7 +61,7 @@ describe('authStateMachine', () => { generator.next(); // Fork appLockStateMachine generator.next(); - expect(generator.next().value).toEqual(take(LOGOUT)); + expect(generator.next().value).toEqual(take(UserActionType.LOGOUT)); expect(generator.next().value).toEqual(cancel()); }); }); @@ -60,7 +73,7 @@ describe('appLockStateMachine', () => { it('should fork biometricsStateMachine when app is locked', async () => { const generator = appLockStateMachine(); - expect(generator.next().value).toEqual(take(LOCKED_APP)); + expect(generator.next().value).toEqual(take(UserActionType.LOCKED_APP)); // Fork biometrics listener. expect(generator.next().value).toEqual( fork(biometricsStateMachine, mockBioStateMachineId), @@ -90,7 +103,11 @@ describe('biometricsStateMachine', () => { const generator = biometricsStateMachine(mockBioStateMachineId); // Take next step expect(generator.next().value).toEqual( - take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]), + take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]), ); // Dispatch interrupt biometrics const nextFork = generator.next(interruptBiometrics() as Action).value; @@ -127,3 +144,44 @@ describe('biometricsStateMachine', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + +// TODO: Update all saga tests to use expectSaga (more intuitive and easier to read) +describe('startAppServices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start app services', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are started + expect(EngineService.start).toHaveBeenCalled(); + expect(AppStateEventProcessor.start).toHaveBeenCalled(); + }); + + it('should not start app services if navigation is not ready', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); + + it('should not start app services if persisted data is not loaded', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index 20ed3cd2a88..d7e1c2b64da 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -7,6 +7,7 @@ import { initialState as transactionMetrics } from '../../core/redux/slices/tran import { initialState as originThrottling } from '../../core/redux/slices/originThrottling'; import initialBackgroundState from './initial-background-state.json'; import { userInitialState } from '../../reducers/user'; +import { initialNavigationState } from '../../reducers/navigation'; import { initialState as initialStakingState } from '../../core/redux/slices/staking'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { initialState as initialMultichainSettingsState } from '../../reducers/multichain'; @@ -35,7 +36,7 @@ const initialRootState: RootState = { swaps: undefined, fiatOrders: initialFiatOrdersState, infuraAvailability: undefined, - navigation: undefined, + navigation: initialNavigationState, networkOnboarded: undefined, security: initialSecurityState, signatureRequest: undefined, diff --git a/babel.config.js b/babel.config.js index af4c36a0a77..8b3aa390673 100644 --- a/babel.config.js +++ b/babel.config.js @@ -23,6 +23,12 @@ module.exports = { test: './app/lib/snaps', plugins: [['babel-plugin-inline-import', { extensions: ['.html'] }]], }, + // TODO: Remove this once we have a fix for the private methods + // Do not apply this plugin globally since it breaks FlatList props.getItem + { + test: './app/core/redux/ReduxService.ts', + plugins: [['@babel/plugin-transform-private-methods', { loose: true }]], + }, ], env: { production: { diff --git a/package.json b/package.json index f27eefc20d4..36e526e6b4d 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,7 @@ "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.2.0", "redux-flipper": "^2.0.3", + "redux-saga-test-plan": "^4.0.6", "regenerator-runtime": "0.13.9", "rn-nodeify": "10.3.0", "serve-handler": "^6.1.5", diff --git a/yarn.lock b/yarn.lock index aa0795670d9..f8e9220e36c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17874,6 +17874,11 @@ fsevents@^2.3.2, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +fsm-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fsm-iterator/-/fsm-iterator-1.1.0.tgz#337de45de19eb205788cf02e3a955ec206760dec" + integrity sha512-hg47CNYdIGJ5m9WSKh617LHRdvJo4PiF0VkncFLwPVxKvBEQfSPd1qx/xLV/eSusewEu0C8eUFrsLsWlBgIcOg== + ftp-response-parser@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz#3b9d33f8edd5fb8e4700b8f778c462e5b1581f89" @@ -21185,6 +21190,11 @@ lodash.isequal@4.5.0, lodash.isequal@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== +lodash.ismatch@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" + integrity sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g== + lodash.isobject@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" @@ -25607,6 +25617,15 @@ redux-persist@6.0.0: resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== +redux-saga-test-plan@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/redux-saga-test-plan/-/redux-saga-test-plan-4.0.6.tgz#0e50a68f63083fbda4bb20cc087833d5b84ace77" + integrity sha512-ESdbFoDWCeJ/EiFdUNSCGtA2CC9tnuvHDm6k06gVFa98EIeR2hpzFkGk9kJ1/hpMUnYFp+OOEEITIrZeDYBfFg== + dependencies: + fsm-iterator "^1.1.0" + lodash.isequal "^4.5.0" + lodash.ismatch "^4.4.0" + redux-saga@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.3.0.tgz#a59ada7c28010189355356b99738c9fcb7ade30e" From 9d63e3407607e32e7fadc2b33c2d776193d930a3 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 10 Dec 2024 12:19:07 +0100 Subject: [PATCH 3/3] chore: update user storage E2E framework (#12609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates user storage related E2E tests. - `userStorageMockttpController` now uses the `USER_STORAGE_FEATURE_NAMES` constant in order to define paths - `userStorageMockttpController` now supports batch deleting items - E2E tests now use `USER_STORAGE_FEATURE_NAMES` to define paths ## **Related issues** Fixes: ## **Manual testing steps** 1. No testing steps, user storage E2E tests are disabled for now ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- ...c-after-adding-custom-name-account.spec.js | 11 +- .../sync-after-onboarding.spec.js | 11 +- e2e/specs/identity/utils/mocks.js | 11 +- .../userStorageMockttpController.js | 111 ++++--- .../userStorageMockttpController.test.js | 274 +++++++++++++----- e2e/specs/notifications/utils/mocks.js | 6 +- 6 files changed, 300 insertions(+), 124 deletions(-) diff --git a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js index a586d49349a..98931feee82 100644 --- a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js @@ -18,6 +18,7 @@ import AddAccountBottomSheet from '../../../pages/wallet/AddAccountBottomSheet'; import AccountActionsBottomSheet from '../../../pages/wallet/AccountActionsBottomSheet'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { const NEW_ACCOUNT_NAME = 'My third account'; @@ -33,9 +34,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); decryptedAccountNames = await Promise.all( accountsSyncMockResponse.map(async (response) => { diff --git a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js index 83fdd49c635..4930a9cf1be 100644 --- a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js @@ -16,6 +16,7 @@ import AccountListBottomSheet from '../../../pages/wallet/AccountListBottomSheet import Assertions from '../../../utils/Assertions'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { beforeAll(async () => { @@ -27,9 +28,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); jest.setTimeout(200000); await TestHelpers.reverseServerPort(); diff --git a/e2e/specs/identity/utils/mocks.js b/e2e/specs/identity/utils/mocks.js index a8509c988e0..f8203ffb69c 100644 --- a/e2e/specs/identity/utils/mocks.js +++ b/e2e/specs/identity/utils/mocks.js @@ -1,6 +1,7 @@ import { AuthenticationController } from '@metamask/profile-sync-controller'; import { UserStorageMockttpController } from './user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const AuthMocks = AuthenticationController.Mocks; @@ -20,8 +21,14 @@ export async function mockIdentityServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('accounts', server); - userStorageMockttpControllerInstance.setupPath('networks', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.networks, + server, + ); return { userStorageMockttpControllerInstance, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js index 9438e2a7fc7..8ffafe83d34 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js @@ -1,16 +1,25 @@ +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { determineIfFeatureEntryFromURL, getDecodedProxiedURL, } from '../helpers'; -// TODO: Export user storage schema from @metamask/profile-sync-controller +const baseUrl = + 'https://user-storage\\.api\\.cx\\.metamask\\.io\\/api\\/v1\\/userstorage'; + export const pathRegexps = { - accounts: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/accounts/u, - networks: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/networks/u, - notifications: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/notifications/u, + [USER_STORAGE_FEATURE_NAMES.accounts]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.networks]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.networks}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.notifications]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.notifications}`, + 'u', + ), }; export class UserStorageMockttpController { @@ -57,44 +66,68 @@ export class UserStorageMockttpController { const data = await request.body.getJson(); - const newOrUpdatedSingleOrBatchEntries = - isFeatureEntry && typeof data?.data === 'string' - ? [ - { - HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), - Data: data?.data, - }, - ] - : Object.entries(data?.data).map(([key, value]) => ({ - HashedKey: key, - Data: value, - })); - - newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + // We're handling batch delete inside the PUT method due to API limitations + if (data?.batch_delete) { + const keysToDelete = data.batch_delete; + const internalPathData = this.paths.get(path); if (!internalPathData) { - return; + return { + statusCode, + }; } - const doesThisEntryExist = internalPathData.response?.find( - (existingEntry) => existingEntry.HashedKey === entry.HashedKey, - ); + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.filter( + (entry) => !keysToDelete.includes(entry.HashedKey), + ), + }); + } - if (doesThisEntryExist) { - this.paths.set(path, { - ...internalPathData, - response: internalPathData.response.map((existingEntry) => - existingEntry.HashedKey === entry.HashedKey ? entry : existingEntry, - ), - }); - } else { - this.paths.set(path, { - ...internalPathData, - response: [...(internalPathData?.response || []), entry], - }); - } - }); + if (data?.data) { + const newOrUpdatedSingleOrBatchEntries = + isFeatureEntry && typeof data?.data === 'string' + ? [ + { + HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), + Data: data?.data, + }, + ] + : Object.entries(data?.data).map(([key, value]) => ({ + HashedKey: key, + Data: value, + })); + + newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + const internalPathData = this.paths.get(path); + + if (!internalPathData) { + return; + } + + const doesThisEntryExist = internalPathData.response?.find( + (existingEntry) => existingEntry.HashedKey === entry.HashedKey, + ); + + if (doesThisEntryExist) { + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.map((existingEntry) => + existingEntry.HashedKey === entry.HashedKey + ? entry + : existingEntry, + ), + }); + } else { + this.paths.set(path, { + ...internalPathData, + response: [...(internalPathData?.response || []), entry], + }); + } + }); + } return { statusCode, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js index 5ea0f1603ca..dc7ee91cfa3 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js @@ -1,5 +1,6 @@ import { getLocal } from 'mockttp'; import { UserStorageMockttpController } from './userStorageMockttpController'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe('UserStorageMockttpController', () => { let mockServer; @@ -13,11 +14,17 @@ describe('UserStorageMockttpController', () => { it('handles GET requests that have empty response', async () => { const controller = new UserStorageMockttpController(); - await controller.setupPath('accounts', mockServer); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(null); }); @@ -37,13 +44,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -63,13 +77,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -89,13 +110,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, + }, + ); expect(request.json).toEqual(mockedData[0]); }); @@ -120,24 +148,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, - body: { - getJson: async () => ({ - data: mockedAddedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, + body: { + getJson: async () => ({ + data: mockedAddedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([...mockedData, mockedAddedData]); }); @@ -162,24 +200,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - body: { - getJson: async () => ({ - data: mockedUpdatedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + body: { + getJson: async () => ({ + data: mockedUpdatedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0], mockedUpdatedData]); }); @@ -211,29 +259,39 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); const putData = {}; mockedUpdatedData.forEach((entry) => { putData[entry.HashedKey] = entry.Data; }); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts`, - body: { - getJson: async () => ({ - data: putData, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + data: putData, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual(mockedUpdatedData); }); @@ -253,19 +311,29 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0]]); }); @@ -283,22 +351,76 @@ describe('UserStorageMockttpController', () => { 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', Data: 'data2', }, + { + HashedKey: + 'x236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data3', + }, ]; - await controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts`, - }); + const deleteRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + batch_delete: [mockedData[1].HashedKey, mockedData[2].HashedKey], + }), + }, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(getRequest.json).toEqual([mockedData[0]]); + }); + + it('handles entire feature DELETE requests', async () => { + const controller = new UserStorageMockttpController(); + const mockedData = [ + { + HashedKey: + '7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b', + Data: 'data1', + }, + { + HashedKey: + 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data2', + }, + ]; + + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { + getResponse: mockedData, }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(deleteRequest.statusCode).toEqual(204); + + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + expect(getRequest.json).toEqual(null); }); }); diff --git a/e2e/specs/notifications/utils/mocks.js b/e2e/specs/notifications/utils/mocks.js index dbf8e8b5e73..72db127ca3a 100644 --- a/e2e/specs/notifications/utils/mocks.js +++ b/e2e/specs/notifications/utils/mocks.js @@ -4,6 +4,7 @@ import { } from '@metamask/notification-services-controller'; import { UserStorageMockttpController } from '../../identity/utils/user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const NotificationMocks = NotificationServicesController.Mocks; const PushMocks = NotificationServicesPushController.Mocks; @@ -19,7 +20,10 @@ export async function mockNotificationServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('notifications', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.notifications, + server, + ); // Notifications mockAPICall(server, NotificationMocks.getMockFeatureAnnouncementResponse());