diff --git a/app/env.ts b/app/env.ts index c5e041280..52eacf506 100644 --- a/app/env.ts +++ b/app/env.ts @@ -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; diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index 17f90209b..d7dfb4ed5 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -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; diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index 28f9abe5f..6bfbc9c2e 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -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( diff --git a/app/src/screens/kyc/KycSuccessScreen.tsx b/app/src/screens/kyc/KycSuccessScreen.tsx index 22849692c..3269da141 100644 --- a/app/src/screens/kyc/KycSuccessScreen.tsx +++ b/app/src/screens/kyc/KycSuccessScreen.tsx @@ -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 = ({ + route: { params }, +}) => { const navigation = useNavigation>(); + 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(); diff --git a/app/src/services/notifications/notificationService.ts b/app/src/services/notifications/notificationService.ts index 87fe09ec6..159201e34 100644 --- a/app/src/services/notifications/notificationService.ts +++ b/app/src/services/notifications/notificationService.ts @@ -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<{ diff --git a/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx b/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx index ea8cc6c72..48d37a7d7 100644 --- a/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx +++ b/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx @@ -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) => {children}, })); -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) => ( - ), PrimaryButton: ({ children, onPress }: any) => ( - ), SecondaryButton: ({ children, onPress }: any) => ( - ), @@ -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(); + const { root } = render(); expect(root).toBeTruthy(); }); it('should have navigation available', () => { - render(); + render(); expect(mockUseNavigation).toHaveBeenCalled(); }); it('should have notification service available', () => { - render(); + render(); expect(notificationService.requestNotificationPermission).toBeDefined(); }); + it('should fetch and register FCM token when "Receive live updates" is pressed', async () => { + const { root } = render(); + + 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(); + + 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(); + + 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(); + + 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