mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
feat(kyc): register fcm token for sumsub verification (#1673)
* feat(kyc): register fcm token for sumsub verification * fix tests * remove unused import * fix lint
This commit is contained in:
committed by
GitHub
parent
f11e860659
commit
a6c84d80f7
@@ -27,6 +27,7 @@ export const IS_TEST_BUILD = process.env.IS_TEST_BUILD === 'true';
|
||||
|
||||
export const MIXPANEL_NFC_PROJECT_TOKEN = undefined;
|
||||
export const SEGMENT_KEY = process.env.SEGMENT_KEY;
|
||||
export const SELF_UUID_NAMESPACE = process.env.SELF_UUID_NAMESPACE;
|
||||
export const SENTRY_DSN = process.env.SENTRY_DSN;
|
||||
export const SUMSUB_TEE_URL =
|
||||
process.env.SUMSUB_TEE_URL || 'http://localhost:8080';
|
||||
@@ -34,6 +35,5 @@ export const SUMSUB_TEST_TOKEN = process.env.SUMSUB_TEST_TOKEN;
|
||||
|
||||
export const TURNKEY_AUTH_PROXY_CONFIG_ID =
|
||||
process.env.TURNKEY_AUTH_PROXY_CONFIG_ID;
|
||||
|
||||
export const TURNKEY_GOOGLE_CLIENT_ID = process.env.TURNKEY_GOOGLE_CLIENT_ID;
|
||||
export const TURNKEY_ORGANIZATION_ID = process.env.TURNKEY_ORGANIZATION_ID;
|
||||
|
||||
@@ -79,6 +79,7 @@ export type RootStackParamList = Omit<
|
||||
| 'Home'
|
||||
| 'IDPicker'
|
||||
| 'IdDetails'
|
||||
| 'KycSuccess'
|
||||
| 'RegistrationFallback'
|
||||
| 'Loading'
|
||||
| 'Modal'
|
||||
@@ -201,7 +202,11 @@ export type RootStackParamList = Omit<
|
||||
|
||||
// Onboarding screens
|
||||
Disclaimer: undefined;
|
||||
KycSuccess: undefined;
|
||||
KycSuccess:
|
||||
| {
|
||||
userId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Dev screens
|
||||
CreateMock: undefined;
|
||||
|
||||
@@ -380,7 +380,9 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
// Success case: navigate to KYC success screen
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KycSuccess');
|
||||
navigationRef.navigate('KycSuccess', {
|
||||
userId: accessToken.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const safeInitError = sanitizeErrorMessage(
|
||||
|
||||
@@ -2,37 +2,70 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { YStack } from 'tamagui';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import type { StaticScreenProps } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { DelayedLottieView, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json';
|
||||
import {
|
||||
AbstractButton,
|
||||
Description,
|
||||
Title,
|
||||
} from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { requestNotificationPermission } from '@/services/notifications/notificationService';
|
||||
import {
|
||||
getFCMToken,
|
||||
getSelfUuidNamespace,
|
||||
registerDeviceToken,
|
||||
requestNotificationPermission,
|
||||
} from '@/services/notifications/notificationService';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
const KycSuccessScreen: React.FC = () => {
|
||||
type KycSuccessRouteParams = StaticScreenProps<
|
||||
| {
|
||||
userId?: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
|
||||
const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
|
||||
route: { params },
|
||||
}) => {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const userId = params?.userId;
|
||||
const insets = useSafeAreaInsets();
|
||||
const setFcmToken = useSettingStore(state => state.setFcmToken);
|
||||
const selfClient = useSelfClient();
|
||||
const { trackEvent } = selfClient;
|
||||
|
||||
const handleReceiveUpdates = async () => {
|
||||
const handleReceiveUpdates = useCallback(async () => {
|
||||
buttonTap();
|
||||
await requestNotificationPermission();
|
||||
|
||||
if ((await requestNotificationPermission()) && userId) {
|
||||
const token = await getFCMToken();
|
||||
if (token) {
|
||||
setFcmToken(token);
|
||||
trackEvent(ProofEvents.FCM_TOKEN_STORED);
|
||||
|
||||
const sessionId = uuidv5(userId, getSelfUuidNamespace());
|
||||
await registerDeviceToken(sessionId, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to Home regardless of permission result
|
||||
navigation.navigate('Home', {});
|
||||
};
|
||||
}, [navigation, setFcmToken, trackEvent, userId]);
|
||||
|
||||
const handleCheckLater = () => {
|
||||
buttonTap();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { PermissionsAndroid, Platform } from 'react-native';
|
||||
import { SELF_UUID_NAMESPACE } from '@env';
|
||||
import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging';
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
|
||||
@@ -36,6 +37,10 @@ const error = (...args: unknown[]) => {
|
||||
if (!isTestEnv) console.error(...args);
|
||||
};
|
||||
|
||||
export function getSelfUuidNamespace(): string {
|
||||
return SELF_UUID_NAMESPACE ?? '';
|
||||
}
|
||||
|
||||
export { getStateMessage };
|
||||
|
||||
export async function isNotificationSystemReady(): Promise<{
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { render } from '@testing-library/react-native';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import KycSuccessScreen from '@/screens/kyc/KycSuccessScreen';
|
||||
@@ -46,10 +47,6 @@ jest.mock('tamagui', () => ({
|
||||
Text: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
DelayedLottieView: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
|
||||
black: '#000000',
|
||||
white: '#FFFFFF',
|
||||
@@ -57,17 +54,17 @@ jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
AbstractButton: ({ children, onPress }: any) => (
|
||||
<button onClick={onPress} data-testid="abstract-button" type="button">
|
||||
<button onClick={onPress} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PrimaryButton: ({ children, onPress }: any) => (
|
||||
<button onClick={onPress} data-testid="primary-button" type="button">
|
||||
<button onClick={onPress} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SecondaryButton: ({ children, onPress }: any) => (
|
||||
<button onClick={onPress} data-testid="secondary-button" type="button">
|
||||
<button onClick={onPress} type="button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
@@ -77,12 +74,23 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
|
||||
ProofEvents: {
|
||||
FCM_TOKEN_STORED: 'FCM_TOKEN_STORED',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/animations/loading/misc.json', () => ({}));
|
||||
|
||||
jest.mock('@/integrations/haptics', () => ({
|
||||
buttonTap: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/services/notifications/notificationService', () => ({
|
||||
requestNotificationPermission: jest.fn(),
|
||||
getFCMToken: jest.fn(),
|
||||
registerDeviceToken: jest.fn(),
|
||||
getSelfUuidNamespace: jest.fn(() => '1eebc0f5-eee9-45a4-9474-a0d103b9f20c'),
|
||||
}));
|
||||
|
||||
jest.mock('@/config/sentry', () => ({
|
||||
@@ -94,12 +102,36 @@ jest.mock('@/services/analytics', () => ({
|
||||
trackNfcEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
DelayedLottieView: () => null,
|
||||
useSelfClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
|
||||
// Import mocked modules
|
||||
const { useSelfClient } = jest.requireMock('@selfxyz/mobile-sdk-alpha');
|
||||
const { useSettingStore } = jest.requireMock('@/stores/settingStore');
|
||||
|
||||
const MOCK_SELF_UUID_NAMESPACE = '1eebc0f5-eee9-45a4-9474-a0d103b9f20c';
|
||||
|
||||
describe('KycSuccessScreen', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockSetFcmToken = jest.fn();
|
||||
const mockUserId = '19f21362-856a-4606-88e1-fa306036978f';
|
||||
const mockFcmToken = 'mock-fcm-token';
|
||||
const mockRoute = {
|
||||
params: {
|
||||
userId: mockUserId,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -107,23 +139,165 @@ describe('KycSuccessScreen', () => {
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
} as any);
|
||||
|
||||
useSelfClient.mockReturnValue({
|
||||
trackEvent: mockTrackEvent,
|
||||
});
|
||||
|
||||
useSettingStore.mockReturnValue(mockSetFcmToken);
|
||||
|
||||
(
|
||||
notificationService.requestNotificationPermission as jest.Mock
|
||||
).mockResolvedValue(true);
|
||||
(notificationService.getFCMToken as jest.Mock).mockResolvedValue(
|
||||
mockFcmToken,
|
||||
);
|
||||
(notificationService.registerDeviceToken as jest.Mock).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render the screen without errors', () => {
|
||||
const { root } = render(<KycSuccessScreen />);
|
||||
const { root } = render(<KycSuccessScreen route={mockRoute} />);
|
||||
expect(root).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have navigation available', () => {
|
||||
render(<KycSuccessScreen />);
|
||||
render(<KycSuccessScreen route={mockRoute} />);
|
||||
expect(mockUseNavigation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have notification service available', () => {
|
||||
render(<KycSuccessScreen />);
|
||||
render(<KycSuccessScreen route={mockRoute} />);
|
||||
expect(notificationService.requestNotificationPermission).toBeDefined();
|
||||
});
|
||||
|
||||
it('should fetch and register FCM token when "Receive live updates" is pressed', async () => {
|
||||
const { root } = render(<KycSuccessScreen route={mockRoute} />);
|
||||
|
||||
const buttons = root.findAllByType('button');
|
||||
const receiveUpdatesButton = buttons[0]; // First button is "Receive live updates"
|
||||
fireEvent.press(receiveUpdatesButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify notification permission was requested
|
||||
expect(
|
||||
notificationService.requestNotificationPermission,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify FCM token was fetched
|
||||
expect(notificationService.getFCMToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify FCM token was stored in settings store
|
||||
expect(mockSetFcmToken).toHaveBeenCalledWith(mockFcmToken);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify tracking event was sent
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('FCM_TOKEN_STORED');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify device token was registered with deterministic session ID
|
||||
expect(notificationService.registerDeviceToken).toHaveBeenCalledWith(
|
||||
uuidv5(mockUserId, MOCK_SELF_UUID_NAMESPACE),
|
||||
mockFcmToken,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify navigation to Home screen
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Home', {});
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to Home without FCM token when permission is denied', async () => {
|
||||
(
|
||||
notificationService.requestNotificationPermission as jest.Mock
|
||||
).mockResolvedValue(false);
|
||||
|
||||
const { root } = render(<KycSuccessScreen route={mockRoute} />);
|
||||
|
||||
const buttons = root.findAllByType('button');
|
||||
const receiveUpdatesButton = buttons[0]; // First button is "Receive live updates"
|
||||
fireEvent.press(receiveUpdatesButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify notification permission was requested
|
||||
expect(
|
||||
notificationService.requestNotificationPermission,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Verify FCM token was NOT fetched
|
||||
expect(notificationService.getFCMToken).not.toHaveBeenCalled();
|
||||
|
||||
// Verify FCM token was NOT stored
|
||||
expect(mockSetFcmToken).not.toHaveBeenCalled();
|
||||
|
||||
// Verify device token was NOT registered
|
||||
expect(notificationService.registerDeviceToken).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify navigation to Home screen still happens
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Home', {});
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to Home when "I will check back later" is pressed', () => {
|
||||
const { root } = render(<KycSuccessScreen route={mockRoute} />);
|
||||
|
||||
const buttons = root.findAllByType('button');
|
||||
const checkLaterButton = buttons[1]; // Second button is "I will check back later"
|
||||
fireEvent.press(checkLaterButton);
|
||||
|
||||
// Verify navigation to Home screen
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Home', {});
|
||||
|
||||
// Verify FCM-related functions were NOT called
|
||||
expect(
|
||||
notificationService.requestNotificationPermission,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(notificationService.getFCMToken).not.toHaveBeenCalled();
|
||||
expect(mockSetFcmToken).not.toHaveBeenCalled();
|
||||
expect(notificationService.registerDeviceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing userId gracefully', async () => {
|
||||
const routeWithoutUserId = {
|
||||
params: {},
|
||||
};
|
||||
|
||||
(
|
||||
notificationService.requestNotificationPermission as jest.Mock
|
||||
).mockResolvedValue(true);
|
||||
|
||||
const { root } = render(<KycSuccessScreen route={routeWithoutUserId} />);
|
||||
|
||||
const buttons = root.findAllByType('button');
|
||||
const receiveUpdatesButton = buttons[0]; // First button is "Receive live updates"
|
||||
fireEvent.press(receiveUpdatesButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify notification permission was requested
|
||||
expect(
|
||||
notificationService.requestNotificationPermission,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Verify FCM token was NOT fetched (no userId)
|
||||
expect(notificationService.getFCMToken).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify navigation to Home screen still happens
|
||||
expect(mockNavigate).toHaveBeenCalledWith('Home', {});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fallback on render error', () => {
|
||||
// Mock console.error to suppress error boundary error logs during test
|
||||
const consoleErrorSpy = jest
|
||||
|
||||
Reference in New Issue
Block a user