Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion src/store/sumsub/sumsub.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
});
});

// ---------------------------------------------------------------------------
Expand Down
42 changes: 40 additions & 2 deletions src/store/sumsub/sumsub.effects.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {t} from 'i18next';
import {Effect} from '../index';
import {SumSubApi} from '../../api/sumsub';
import {launchSumSubSdk} from '../../lib/sumsub';
Expand All @@ -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 =
Expand Down Expand Up @@ -57,22 +62,47 @@ 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;
}

const getAccessToken = (): Promise<string | null> =>
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;
}

Expand Down Expand Up @@ -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.'),
}),
),
);
}
};
Loading