diff --git a/src/store/sumsub/sumsub.effects.spec.ts b/src/store/sumsub/sumsub.effects.spec.ts index e0ab2ceef..a1dc60c18 100644 --- a/src/store/sumsub/sumsub.effects.spec.ts +++ b/src/store/sumsub/sumsub.effects.spec.ts @@ -7,6 +7,7 @@ import {Network} from '../../constants'; import {startKycVerification, startGetKycStatus} from './sumsub.effects'; import {SumSubApi} from '../../api/sumsub'; import {launchSumSubSdk} from '../../lib/sumsub'; +import {ongoingProcessManager} from '../../managers/OngoingProcessManager'; // --------------------------------------------------------------------------- // Mocks @@ -32,9 +33,20 @@ jest.mock('../../lib/sumsub', () => ({ launchSumSubSdk: jest.fn(), })); +jest.mock('../../managers/OngoingProcessManager', () => ({ + ongoingProcessManager: { + show: jest.fn(), + hide: jest.fn(), + }, +})); + const mockFetchAccessToken = SumSubApi.fetchAccessToken as jest.Mock; const mockFetchKycStatus = SumSubApi.fetchKycStatus as jest.Mock; const mockLaunchSumSubSdk = launchSumSubSdk as jest.Mock; +const mockOngoingProcess = ongoingProcessManager as unknown as { + show: jest.Mock; + hide: jest.Mock; +}; // --------------------------------------------------------------------------- // Helper: build a store seeded with a logged-in user @@ -95,6 +107,17 @@ describe('startKycVerification — auth guard', () => { expect(mockFetchAccessToken).not.toHaveBeenCalled(); expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); }); + + it('tells the user to log in instead of failing silently', async () => { + const store = configureTestStore({ + APP: {network: Network.mainnet}, + BITPAY_ID: {user: {}, apiToken: {[Network.mainnet]: API_TOKEN}}, + }); + + await store.dispatch(startKycVerification()); + + expect(store.getState().APP.showBottomNotificationModal).toBe(true); + }); }); // --------------------------------------------------------------------------- @@ -236,7 +259,15 @@ describe('startKycVerification — failure handling', () => { expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); expect(store.getState().SUMSUB.kyc[Network.mainnet]).toBeNull(); - expect(store.getState().APP.showBottomNotificationModal).not.toBe(true); + }); + + it('surfaces a message when the backend returns no token', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockResolvedValue(null); + + await store.dispatch(startKycVerification()); + + expect(store.getState().APP.showBottomNotificationModal).toBe(true); }); it('resolves when fetching the access token fails', async () => { @@ -249,6 +280,55 @@ describe('startKycVerification — failure handling', () => { expect(mockLaunchSumSubSdk).not.toHaveBeenCalled(); expect(store.getState().SUMSUB.kyc[Network.mainnet]).toBeNull(); }); + + it('never leaks the raw server error into the modal', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockRejectedValue( + new Error('Region not supported for identity verification'), + ); + + await store.dispatch(startKycVerification()); + + const {message} = store.getState().APP.bottomNotificationModalConfig!; + expect(store.getState().APP.showBottomNotificationModal).toBe(true); + expect(message).toBe('The verification process encountered an error.'); + expect(message).not.toContain('Region not supported'); + }); +}); + +// --------------------------------------------------------------------------- +// Loading indicator around the token mint +// --------------------------------------------------------------------------- +describe('startKycVerification — spinner', () => { + it('shows and hides the spinner around the token mint', async () => { + const store = makeLoggedInStore(); + + await store.dispatch(startKycVerification()); + + expect(mockOngoingProcess.show).toHaveBeenCalledWith('GENERAL_AWAITING'); + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + }); + + it('hides the spinner even when the token mint throws', async () => { + const store = makeLoggedInStore(); + mockFetchAccessToken.mockRejectedValue(new Error('token endpoint down')); + + await store.dispatch(startKycVerification()); + + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + }); + + it('hides the spinner before the SDK is launched', async () => { + const store = makeLoggedInStore(); + mockLaunchSumSubSdk.mockImplementation(async () => { + expect(mockOngoingProcess.hide).toHaveBeenCalledTimes(1); + return {success: true, status: 'Approved'}; + }); + + await store.dispatch(startKycVerification()); + + expect(mockLaunchSumSubSdk).toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/store/sumsub/sumsub.effects.ts b/src/store/sumsub/sumsub.effects.ts index dbe67f916..254fe3b37 100644 --- a/src/store/sumsub/sumsub.effects.ts +++ b/src/store/sumsub/sumsub.effects.ts @@ -1,3 +1,4 @@ +import {t} from 'i18next'; import {Effect} from '../index'; import {SumSubApi} from '../../api/sumsub'; import {launchSumSubSdk} from '../../lib/sumsub'; @@ -7,6 +8,10 @@ import {KycInfo} from './sumsub.reducer'; import {showBottomNotificationModal} from '../app/app.actions'; import {CustomErrorMessage} from '../../navigation/wallet/components/ErrorMessages'; import {deriveKycUiState} from './sumsub.selectors'; +import {ongoingProcessManager} from '../../managers/OngoingProcessManager'; +import {sleep} from '../../utils/helper-methods'; + +const MODAL_HANDOFF_DELAY = 600; // Fetches the backend KYC object and stores it verbatim. No-op when logged out. export const startGetKycStatus = @@ -57,6 +62,15 @@ export const startKycVerification = dispatch( LogActions.error('[SumSub] Cannot start KYC — user not logged in'), ); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + errMsg: t( + 'Please log in to your BitPay account to verify your identity.', + ), + }), + ), + ); return; } @@ -64,15 +78,31 @@ export const startKycVerification = SumSubApi.fetchAccessToken(apiToken); try { - const accessToken = await getAccessToken(); + ongoingProcessManager.show('GENERAL_AWAITING'); + let accessToken: string | null; + try { + accessToken = await getAccessToken(); + } finally { + ongoingProcessManager.hide(); + } - // Null token → not eligible; not an error, just don't launch the SDK. if (!accessToken) { dispatch( LogActions.info( '[SumSub] No access token returned — KYC not available for this user.', ), ); + await sleep(MODAL_HANDOFF_DELAY); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + title: t('Verification unavailable'), + errMsg: t( + "Identity verification isn't available for your account at this time. Please contact support if you need help.", + ), + }), + ), + ); return; } @@ -114,5 +144,13 @@ export const startKycVerification = } catch (err) { const msg = err instanceof Error ? err.message : JSON.stringify(err); dispatch(LogActions.error(`[SumSub] SDK error: ${msg}`)); + await sleep(MODAL_HANDOFF_DELAY); + dispatch( + showBottomNotificationModal( + CustomErrorMessage({ + errMsg: t('The verification process encountered an error.'), + }), + ), + ); } };