diff --git a/app/src/components/documents/IDSelectorItem.tsx b/app/src/components/documents/IDSelectorItem.tsx index 400097399..543de4276 100644 --- a/app/src/components/documents/IDSelectorItem.tsx +++ b/app/src/components/documents/IDSelectorItem.tsx @@ -18,6 +18,7 @@ export interface IDSelectorItemProps { state: IDSelectorState; onPress?: () => void; disabled?: boolean; + isLastItem?: boolean; testID?: string; } @@ -61,6 +62,7 @@ export const IDSelectorItem: React.FC = ({ state, onPress, disabled, + isLastItem, testID, }) => { const isDisabled = disabled || isDisabledState(state); @@ -116,7 +118,7 @@ export const IDSelectorItem: React.FC = ({ - + {!isLastItem && } ); }; diff --git a/app/src/components/documents/IDSelectorSheet.tsx b/app/src/components/documents/IDSelectorSheet.tsx index c7209018d..72fc549b0 100644 --- a/app/src/components/documents/IDSelectorSheet.tsx +++ b/app/src/components/documents/IDSelectorSheet.tsx @@ -2,15 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { - Button, - ScrollView, - Separator, - Sheet, - Text, - XStack, - YStack, -} from 'tamagui'; +import { Button, ScrollView, Sheet, Text, XStack, YStack } from 'tamagui'; import { X } from '@tamagui/lucide-icons'; import { @@ -21,6 +13,7 @@ import { white, } from '@selfxyz/mobile-sdk-alpha/constants/colors'; import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; +import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks'; import type { IDSelectorState } from '@/components/documents/IDSelectorItem'; import { @@ -55,6 +48,8 @@ export const IDSelectorSheet: React.FC = ({ onApprove, testID = 'id-selector-sheet', }) => { + const bottomPadding = useSafeBottomPadding(16); + // Check if the selected document is valid (not expired or unregistered) const selectedDoc = documents.find(d => d.id === selectedId); const canApprove = selectedDoc && !isDisabledState(selectedDoc.state); @@ -105,15 +100,13 @@ export const IDSelectorSheet: React.FC = ({ - - {/* Document List */} - {documents.map(doc => { + {documents.map((doc, index) => { const isSelected = doc.id === selectedId; // Don't override to 'active' if the document is in a disabled state const itemState: IDSelectorState = @@ -127,6 +120,7 @@ export const IDSelectorSheet: React.FC = ({ documentName={doc.name} state={itemState} onPress={() => onSelect(doc.id)} + isLastItem={index === documents.length - 1} testID={`${testID}-item-${doc.id}`} /> ); @@ -134,7 +128,7 @@ export const IDSelectorSheet: React.FC = ({ {/* Footer Buttons */} - + - + {screenList.map(item => ( { @@ -142,18 +179,32 @@ const IDSelectorTestScreen: React.FC = () => { }, [loadRealDocuments]), ); - // Convert real documents to IDSelectorDocument format - const documents: IDSelectorDocument[] = documentCatalog.documents.map( - metadata => { + // Convert real documents to IDSelectorDocument format and sort them + const documents: IDSelectorDocument[] = documentCatalog.documents + .map(metadata => { const docData = allDocuments[metadata.id]; return { id: metadata.id, - name: getDocumentDisplayName(metadata), + name: getDocumentDisplayName(metadata, docData?.data), state: determineDocumentState(metadata, docData?.data), }; - }, - ); + }) + .sort((a, b) => { + // Get metadata for both documents + const metaA = documentCatalog.documents.find(d => d.id === a.id); + const metaB = documentCatalog.documents.find(d => d.id === b.id); + + // Sort real documents before mock documents + if (metaA && metaB) { + if (metaA.mock !== metaB.mock) { + return metaA.mock ? 1 : -1; // Real first + } + } + + // Within same type (real/mock), sort alphabetically by name + return a.name.localeCompare(b.name); + }); const selectedDocument = documents.find(doc => doc.id === selectedId); const selectedName = selectedDocument?.name || 'None'; @@ -202,7 +253,36 @@ const IDSelectorTestScreen: React.FC = () => { ID Selector Test - {documents.length === 0 ? ( + {loading ? ( + /* Loading State */ + + + Loading documents... + + + Please wait while we fetch your documents + + + ) : documents.length === 0 ? ( /* Empty State */ { + const navigation = + useNavigation>(); + const insets = useSafeAreaInsets(); + const selfClient = useSelfClient(); + const { useSelfAppStore } = selfClient; + const selfApp = useSelfAppStore(state => state.selfApp); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + + const [documentCatalog, setDocumentCatalog] = useState({ + documents: [], + }); + const [allDocuments, setAllDocuments] = useState< + Record + >({}); + const [selectedDocumentId, setSelectedDocumentId] = useState< + string | undefined + >(); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + + const logoSource = useMemo(() => { + if (!selfApp?.logoBase64) { + return null; + } + + if ( + selfApp.logoBase64.startsWith('http://') || + selfApp.logoBase64.startsWith('https://') + ) { + return { uri: selfApp.logoBase64 }; + } + + const base64String = selfApp.logoBase64.startsWith('data:image') + ? selfApp.logoBase64 + : `data:image/png;base64,${selfApp.logoBase64}`; + return { uri: base64String }; + }, [selfApp?.logoBase64]); + + const url = useMemo(() => { + if (!selfApp?.endpoint) { + return null; + } + return formatEndpoint(selfApp.endpoint); + }, [selfApp?.endpoint]); + + const pickInitialDocument = useCallback( + ( + catalog: DocumentCatalog, + docs: Record, + ) => { + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find( + doc => doc.id === catalog.selectedDocumentId, + ); + const selectedData = selectedMeta + ? docs[catalog.selectedDocumentId] + : undefined; + + if (selectedMeta && selectedData) { + const state = determineDocumentState(selectedMeta, selectedData.data); + if (!isDisabledState(state)) { + return catalog.selectedDocumentId; + } + } else if (selectedMeta) { + return catalog.selectedDocumentId; + } + } + + const firstValid = catalog.documents.find(doc => { + const docData = docs[doc.id]; + const state = determineDocumentState(doc, docData?.data); + return !isDisabledState(state); + }); + + return firstValid?.id; + }, + [], + ); + + const loadDocuments = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + setLoading(true); + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't update state if this request was aborted + if (controller.signal.aborted) { + return; + } + + setDocumentCatalog(catalog); + setAllDocuments(docs); + setSelectedDocumentId(pickInitialDocument(catalog, docs)); + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents:', loadError); + setError('Unable to load documents.'); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }, [getAllDocuments, loadDocumentCatalog, pickInitialDocument]); + + useFocusEffect( + useCallback(() => { + loadDocuments(); + }, [loadDocuments]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const documents = useMemo(() => { + return documentCatalog.documents + .map(metadata => { + const docData = allDocuments[metadata.id]; + const baseState = determineDocumentState(metadata, docData?.data); + const isSelected = metadata.id === selectedDocumentId; + const itemState = + isSelected && !isDisabledState(baseState) ? 'active' : baseState; + + return { + id: metadata.id, + name: getDocumentDisplayName(metadata, docData?.data), + state: itemState, + }; + }) + .sort((a, b) => { + // Get metadata for both documents + const metaA = documentCatalog.documents.find(d => d.id === a.id); + const metaB = documentCatalog.documents.find(d => d.id === b.id); + + // Sort real documents before mock documents + if (metaA && metaB) { + if (metaA.mock !== metaB.mock) { + return metaA.mock ? 1 : -1; // Real first + } + } + + // Within same type (real/mock), sort alphabetically by name + return a.name.localeCompare(b.name); + }); + }, [allDocuments, documentCatalog.documents, selectedDocumentId]); + + const selectedDocument = documents.find(doc => doc.id === selectedDocumentId); + const canContinue = + !!selectedDocument && !isDisabledState(selectedDocument.state); + + const handleSelect = (documentId: string) => { + const document = documents.find(doc => doc.id === documentId); + if (!document || isDisabledState(document.state)) { + return; + } + setSelectedDocumentId(documentId); + }; + + const handleContinue = async () => { + if (!selectedDocumentId || !canContinue || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + await setSelectedDocument(selectedDocumentId); + navigation.navigate('Prove'); + } catch (selectionError) { + console.error('Failed to set selected document:', selectionError); + setError('Failed to select document. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + {/* Compact Header */} + + + {logoSource ? ( + + ) : null} + + + {selfApp?.appName || 'Self'} + + {url ? ( + + {url} + + ) : null} + + + + Select an ID to prove your information + + + + {/* Main Content - Document List */} + + Select an ID + {loading ? ( + + + + Loading documents... + + + ) : error ? ( + + + {error} + + + Retry + + + ) : documents.length === 0 ? ( + + + No documents found. Please scan a document first. + + + ) : ( + + {documents.map(doc => ( + handleSelect(doc.id)} + testID={`document-selector-item-${doc.id}`} + /> + ))} + + )} + + + {/* Footer Button */} + + [ + styles.continueButton, + { + backgroundColor: canContinue && !submitting ? blue600 : slate300, + opacity: canContinue && !submitting ? (pressed ? 0.8 : 1) : 0.5, + }, + ]} + testID="document-selector-continue" + > + {submitting ? ( + + ) : ( + Continue to Proof + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: white, + }, + header: { + backgroundColor: black, + paddingHorizontal: 20, + paddingBottom: 16, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + headerTextContainer: { + flex: 1, + marginLeft: 12, + }, + logo: { + width: 40, + height: 40, + }, + appName: { + fontSize: 16, + fontWeight: '600', + color: white, + fontFamily: dinot, + }, + appUrl: { + fontSize: 12, + color: slate300, + fontFamily: dinot, + marginTop: 2, + }, + headerDescription: { + fontSize: 14, + color: slate300, + fontFamily: dinot, + }, + content: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 20, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '600', + color: black, + fontFamily: dinot, + marginBottom: 16, + }, + scrollView: { + flex: 1, + }, + list: { + paddingBottom: 16, + }, + statusContainer: { + flex: 1, + gap: 12, + alignItems: 'center', + justifyContent: 'center', + }, + statusText: { + fontSize: 16, + color: slate500, + textAlign: 'center', + fontFamily: dinot, + }, + actionButton: { + borderRadius: 8, + height: 52, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 16, + }, + retryButton: { + borderWidth: 1, + borderColor: slate300, + backgroundColor: white, + }, + retryButtonText: { + fontSize: 16, + color: slate500, + fontFamily: dinot, + }, + footer: { + paddingHorizontal: 20, + paddingTop: 12, + backgroundColor: white, + borderTopWidth: 1, + borderTopColor: slate300, + }, + continueButton: { + borderRadius: 8, + height: 52, + alignItems: 'center', + justifyContent: 'center', + }, + continueButtonText: { + fontSize: 16, + color: white, + fontFamily: dinot, + }, +}); + +export { DocumentSelectorForProvingScreen }; diff --git a/app/src/screens/verification/ProvingScreenRouter.tsx b/app/src/screens/verification/ProvingScreenRouter.tsx new file mode 100644 index 000000000..5eb112151 --- /dev/null +++ b/app/src/screens/verification/ProvingScreenRouter.tsx @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { + black, + blue600, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts'; + +import type { RootStackParamList } from '@/navigation'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; +import { + checkDocumentExpiration, + getDocumentAttributes, +} from '@/utils/documentAttributes'; + +/** + * Determines if a document is valid for selection (not mock, not expired). + */ +function isValidDocument( + metadata: DocumentMetadata, + documentData: IDDocument | undefined, +): boolean { + // Mock documents are not valid + if (metadata.mock) { + return false; + } + + // Check if expired + if (documentData) { + try { + const attributes = getDocumentAttributes(documentData); + if ( + attributes.expiryDateSlice && + checkDocumentExpiration(attributes.expiryDateSlice) + ) { + return false; + } + } catch { + // If we can't check expiry, assume valid + } + } + + return true; +} + +/** + * Picks the best document to auto-select. + * Prefers the currently selected document if valid, otherwise picks the first valid one. + */ +function pickDocumentToSelect( + catalog: DocumentCatalog, + docs: Record, +): string | undefined { + // Check if currently selected document is valid + if (catalog.selectedDocumentId) { + const selectedMeta = catalog.documents.find( + doc => doc.id === catalog.selectedDocumentId, + ); + const selectedData = selectedMeta + ? docs[catalog.selectedDocumentId] + : undefined; + + if (selectedMeta && isValidDocument(selectedMeta, selectedData?.data)) { + return catalog.selectedDocumentId; + } + } + + // Find first valid document + const firstValid = catalog.documents.find(doc => { + const docData = docs[doc.id]; + return isValidDocument(doc, docData?.data); + }); + + return firstValid?.id; +} + +/** + * Router screen for the proving flow that decides whether to skip the document selector. + * + * This screen: + * 1. Loads document catalog and counts valid documents + * 2. Checks skip settings (skipDocumentSelector, skipDocumentSelectorIfSingle) + * 3. Routes to appropriate screen: + * - No valid documents -> DocumentDataNotFound + * - Skip enabled -> auto-select and go to Prove + * - Otherwise -> DocumentSelectorForProving + */ +const ProvingScreenRouter: React.FC = () => { + const navigation = + useNavigation>(); + const { loadDocumentCatalog, getAllDocuments, setSelectedDocument } = + usePassport(); + const { skipDocumentSelector, skipDocumentSelectorIfSingle } = + useSettingStore(); + + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + const hasRoutedRef = useRef(false); + + const loadAndRoute = useCallback(async () => { + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + // Prevent double routing + if (hasRoutedRef.current) { + return; + } + + setError(null); + try { + const catalog = await loadDocumentCatalog(); + const docs = await getAllDocuments(); + + // Don't continue if this request was aborted + if (controller.signal.aborted) { + return; + } + + // Count valid documents + const validDocuments = catalog.documents.filter(doc => { + const docData = docs[doc.id]; + return isValidDocument(doc, docData?.data); + }); + + const validCount = validDocuments.length; + + // Mark as routed to prevent re-routing + hasRoutedRef.current = true; + + // Route based on document availability and skip settings + if (validCount === 0) { + // No valid documents - redirect to onboarding + navigation.replace('DocumentDataNotFound'); + return; + } + + // Determine if we should skip the selector + const shouldSkip = + skipDocumentSelector || + (skipDocumentSelectorIfSingle && validCount === 1); + + if (shouldSkip) { + // Auto-select and navigate to Prove + const docToSelect = pickDocumentToSelect(catalog, docs); + if (docToSelect) { + try { + await setSelectedDocument(docToSelect); + navigation.replace('Prove'); + } catch (selectError) { + console.error('Failed to auto-select document:', selectError); + // On error, fall back to showing the selector + hasRoutedRef.current = false; + navigation.replace('DocumentSelectorForProving'); + } + } else { + // No valid document to select, show selector + navigation.replace('DocumentSelectorForProving'); + } + } else { + // Show the document selector + navigation.replace('DocumentSelectorForProving'); + } + } catch (loadError) { + // Don't show error if this request was aborted + if (controller.signal.aborted) { + return; + } + console.warn('Failed to load documents for routing:', loadError); + setError('Unable to load documents.'); + // Reset routed flag to allow retry + hasRoutedRef.current = false; + } + }, [ + getAllDocuments, + loadDocumentCatalog, + navigation, + setSelectedDocument, + skipDocumentSelector, + skipDocumentSelectorIfSingle, + ]); + + useFocusEffect( + useCallback(() => { + // Reset routing flag when screen gains focus + hasRoutedRef.current = false; + loadAndRoute(); + }, [loadAndRoute]), + ); + + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + return ( + + {error ? ( + + {error} + { + hasRoutedRef.current = false; + loadAndRoute(); + }} + > + Tap to retry + + + ) : ( + <> + + Loading documents... + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: black, + alignItems: 'center', + justifyContent: 'center', + gap: 16, + }, + loadingText: { + fontSize: 16, + color: white, + fontFamily: dinot, + }, + errorContainer: { + alignItems: 'center', + gap: 12, + }, + errorText: { + fontSize: 16, + color: white, + fontFamily: dinot, + textAlign: 'center', + }, + retryText: { + fontSize: 14, + color: blue600, + fontFamily: dinot, + }, +}); + +export { ProvingScreenRouter }; diff --git a/app/src/screens/verification/QRCodeViewFinderScreen.tsx b/app/src/screens/verification/QRCodeViewFinderScreen.tsx index eea105562..f01f1942f 100644 --- a/app/src/screens/verification/QRCodeViewFinderScreen.tsx +++ b/app/src/screens/verification/QRCodeViewFinderScreen.tsx @@ -48,7 +48,7 @@ const QRCodeViewFinderScreen: React.FC = () => { const isFocused = useIsFocused(); const [doneScanningQR, setDoneScanningQR] = useState(false); const { top: safeAreaTop } = useSafeAreaInsets(); - const navigateToProve = useHapticNavigation('Prove'); + const navigateToDocumentSelector = useHapticNavigation('ProvingScreenRouter'); // This resets to the default state when we navigate back to this screen useFocusEffect( @@ -91,7 +91,7 @@ const QRCodeViewFinderScreen: React.FC = () => { .startAppListener(selfAppJson.sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } catch (parseError) { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -115,7 +115,7 @@ const QRCodeViewFinderScreen: React.FC = () => { selfClient.getSelfAppState().startAppListener(sessionId); setTimeout(() => { - navigateToProve(); + navigateToDocumentSelector(); }, 100); } else { trackEvent(ProofEvents.QR_SCAN_FAILED, { @@ -129,7 +129,13 @@ const QRCodeViewFinderScreen: React.FC = () => { } } }, - [doneScanningQR, navigation, navigateToProve, trackEvent, selfClient], + [ + doneScanningQR, + navigation, + navigateToDocumentSelector, + trackEvent, + selfClient, + ], ); const shouldRenderCamera = !connectionModalVisible && !doneScanningQR; diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 550bb792d..499234400 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -34,8 +34,12 @@ interface PersistedSettingsState { setKeychainMigrationCompleted: () => void; setLoggingSeverity: (severity: LoggingSeverity) => void; setPointsAddress: (address: string | null) => void; + setSkipDocumentSelector: (value: boolean) => void; + setSkipDocumentSelectorIfSingle: (value: boolean) => void; setSubscribedTopics: (topics: string[]) => void; setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void; + skipDocumentSelector: boolean; + skipDocumentSelectorIfSingle: boolean; subscribedTopics: string[]; toggleCloudBackupEnabled: () => void; turnkeyBackupEnabled: boolean; @@ -135,6 +139,14 @@ export const useSettingStore = create()( setPointsAddress: (address: string | null) => set({ pointsAddress: address }), + // Document selector skip settings + skipDocumentSelector: false, + setSkipDocumentSelector: (value: boolean) => + set({ skipDocumentSelector: value }), + skipDocumentSelectorIfSingle: true, + setSkipDocumentSelectorIfSingle: (value: boolean) => + set({ skipDocumentSelectorIfSingle: value }), + // Non-persisted state (will not be saved to storage) hideNetworkModal: false, setHideNetworkModal: (hideNetworkModal: boolean) => { diff --git a/app/tests/__setup__/mocks/navigation.js b/app/tests/__setup__/mocks/navigation.js index 4dfcf7d38..83159a837 100644 --- a/app/tests/__setup__/mocks/navigation.js +++ b/app/tests/__setup__/mocks/navigation.js @@ -9,10 +9,23 @@ jest.mock('@react-navigation/native', () => { const MockNavigator = (props, _ref) => props.children; MockNavigator.displayName = 'MockNavigator'; + // `useFocusEffect` should behave like an effect: it should not synchronously run + // on every re-render, otherwise any state updates inside the callback can cause + // an infinite render loop in tests. + const focusEffectCallbacks = new WeakSet(); + return { useFocusEffect: jest.fn(callback => { - // Immediately invoke the effect for testing without requiring a container - return callback(); + // Invoke only once per callback instance (per component mount), similar to + // how a real focus effect would run on focus rather than every render. + if ( + typeof callback === 'function' && + !focusEffectCallbacks.has(callback) + ) { + focusEffectCallbacks.add(callback); + return callback(); + } + return undefined; }), useNavigation: jest.fn(() => ({ navigate: jest.fn(), diff --git a/app/tests/src/hooks/useEarnPointsFlow.test.ts b/app/tests/src/hooks/useEarnPointsFlow.test.ts index 40ff79ddd..5b003e3b7 100644 --- a/app/tests/src/hooks/useEarnPointsFlow.test.ts +++ b/app/tests/src/hooks/useEarnPointsFlow.test.ts @@ -268,7 +268,7 @@ describe('useEarnPointsFlow', () => { jest.advanceTimersByTime(100); }); - expect(mockNavigate).toHaveBeenCalledWith('Prove'); + expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter'); }); it('should clear referrer when points disclosure modal is dismissed with referrer', async () => { diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 56b75bbc1..a2ade474e 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -62,6 +62,7 @@ describe('navigation', () => { 'DocumentNFCScan', 'DocumentNFCTrouble', 'DocumentOnboarding', + 'DocumentSelectorForProving', 'Gratification', 'Home', 'IDPicker', @@ -76,7 +77,9 @@ describe('navigation', () => { 'ProofHistory', 'ProofHistoryDetail', 'ProofRequestStatus', + 'ProofSettings', 'Prove', + 'ProvingScreenRouter', 'QRCodeTrouble', 'QRCodeViewFinder', 'RecoverWithPhrase', diff --git a/app/tests/src/navigation/deeplinks.test.ts b/app/tests/src/navigation/deeplinks.test.ts index 1c97a10b3..7ce0027e1 100644 --- a/app/tests/src/navigation/deeplinks.test.ts +++ b/app/tests/src/navigation/deeplinks.test.ts @@ -92,7 +92,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); @@ -118,7 +118,7 @@ describe('deeplinks', () => { const { navigationRef } = require('@/navigation'); expect(navigationRef.reset).toHaveBeenCalledWith({ index: 1, - routes: [{ name: 'Home' }, { name: 'Prove' }], + routes: [{ name: 'Home' }, { name: 'ProvingScreenRouter' }], }); }); diff --git a/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx new file mode 100644 index 000000000..cf5f9209f --- /dev/null +++ b/app/tests/src/screens/verification/DocumentSelectorForProvingScreen.test.tsx @@ -0,0 +1,556 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { useNavigation } from '@react-navigation/native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; + +import type { + DocumentCatalog, + DocumentMetadata, + IDDocument, +} from '@selfxyz/common/utils/types'; +import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; + +import { usePassport } from '@/providers/passportDataProvider'; +import { DocumentSelectorForProvingScreen } from '@/screens/verification/DocumentSelectorForProvingScreen'; + +jest.mock('@/providers/passportDataProvider', () => ({ + usePassport: jest.fn(), +})); + +jest.mock('@/utils/documentAttributes', () => ({ + checkDocumentExpiration: jest.fn( + (expiryDateSlice: string) => expiryDateSlice === 'expired', + ), + getDocumentAttributes: jest.fn( + (documentData: { expiryDateSlice?: string }) => ({ + expiryDateSlice: documentData.expiryDateSlice, + }), + ), +})); + +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseSelfClient = useSelfClient as jest.MockedFunction< + typeof useSelfClient +>; +const mockUsePassport = usePassport as jest.MockedFunction; + +type MockDocumentEntry = { + metadata: DocumentMetadata; + data: IDDocument; +}; + +const createMetadata = ( + overrides: Partial & { id: string }, +): DocumentMetadata => ({ + id: overrides.id, + documentType: overrides.documentType ?? 'us', + documentCategory: overrides.documentCategory ?? 'passport', + data: overrides.data ?? 'mock-data', + mock: overrides.mock ?? false, + isRegistered: overrides.isRegistered, + registeredAt: overrides.registeredAt, +}); + +const createDocumentEntry = ( + metadata: DocumentMetadata, + expiryDateSlice?: string, +): MockDocumentEntry => ({ + metadata, + data: { + documentType: metadata.documentType as any, + documentCategory: metadata.documentCategory as any, + mock: metadata.mock, + expiryDateSlice, + } as unknown as IDDocument, +}); + +const createAllDocuments = (entries: MockDocumentEntry[]) => + entries.reduce< + Record + >((acc, entry) => { + acc[entry.metadata.id] = { + data: entry.data, + metadata: entry.metadata, + }; + return acc; + }, {}); + +const mockSelfApp = { + appName: 'Example App', + endpoint: 'https://example.com', + logoBase64: 'https://example.com/logo.png', + sessionId: 'session-id', +}; + +const mockNavigate = jest.fn(); +const mockLoadDocumentCatalog = jest.fn(); +const mockGetAllDocuments = jest.fn(); +const mockSetSelectedDocument = jest.fn(); + +// Stable passport context to prevent infinite re-renders +const stablePassportContext = { + loadDocumentCatalog: mockLoadDocumentCatalog, + getAllDocuments: mockGetAllDocuments, + setSelectedDocument: mockSetSelectedDocument, +}; + +// Stable navigation object +const stableNavigation = { + navigate: mockNavigate, +}; + +// Stable self client selector function +const stableSelfAppSelector = ( + selector: (state: { selfApp: typeof mockSelfApp }) => unknown, +) => selector({ selfApp: mockSelfApp }); + +// Stable self client object +const stableSelfClient = { + useSelfAppStore: stableSelfAppSelector, +}; + +describe('DocumentSelectorForProvingScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseNavigation.mockReturnValue(stableNavigation as any); + + mockUseSelfClient.mockReturnValue(stableSelfClient as any); + + mockUsePassport.mockReturnValue(stablePassportContext as any); + }); + + it('renders loading state initially', () => { + mockLoadDocumentCatalog.mockReturnValue(new Promise(() => {})); + mockGetAllDocuments.mockResolvedValue({}); + + const { getByTestId } = render(); + + expect(getByTestId('document-selector-loading')).toBeTruthy(); + }); + + it('displays app information from selfApp', async () => { + const catalog: DocumentCatalog = { documents: [] }; + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue({}); + + const { getByTestId, getByText } = render( + , + ); + + await waitFor(() => { + expect(getByTestId('document-selector-logo')).toBeTruthy(); + }); + + expect(getByText('example.com')).toBeTruthy(); + expect(getByText('Example App')).toBeTruthy(); + }); + + it('loads and displays all documents from catalog', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const idCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport, idCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(passport), + createDocumentEntry(idCard), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-list')).toBeTruthy(); + expect(getByTestId('document-selector-item-doc-1')).toBeTruthy(); + expect(getByTestId('document-selector-item-doc-2')).toBeTruthy(); + }); + }); + + it('auto-selects currently selected document if valid', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + }); + }); + + it('auto-selects first valid document if current selection is disabled', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const validCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, validCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(validCard), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-2'); + }); + }); + + it('disabled documents cannot be selected', async () => { + const validPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const expiredPassport = createMetadata({ + id: 'doc-2', + documentType: 'fr', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [validPassport, expiredPassport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(validPassport), + createDocumentEntry(expiredPassport, 'expired'), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-item-doc-2')).toBeTruthy(); + }); + + fireEvent.press(getByTestId('document-selector-item-doc-2')); + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-1'); + }); + }); + + it('continue button is disabled when only expired documents exist', async () => { + const expiredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const expiredCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [expiredPassport, expiredCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(expiredPassport, 'expired'), + createDocumentEntry(expiredCard, 'expired'), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + true, + ); + }); + }); + + it('unregistered documents are selectable for proving', async () => { + const unregisteredPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: false, + }); + const catalog: DocumentCatalog = { + documents: [unregisteredPassport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(unregisteredPassport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + // Unregistered documents should be selectable + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + }); + + it('continue button is enabled when valid document selected', async () => { + const validPassport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [validPassport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(validPassport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + }); + + it('selecting a different document updates selection state', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const idCard = createMetadata({ + id: 'doc-2', + documentType: 'ca', + documentCategory: 'id_card', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport, idCard], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([ + createDocumentEntry(passport), + createDocumentEntry(idCard), + ]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-item-doc-2')).toBeTruthy(); + }); + + fireEvent.press(getByTestId('document-selector-item-doc-2')); + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(mockSetSelectedDocument).toHaveBeenCalledWith('doc-2'); + }); + }); + + it('clicking Continue navigates to the Prove screen', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('Prove'); + }); + }); + + it('shows error state when document loading fails', async () => { + mockLoadDocumentCatalog.mockRejectedValue(new Error('failure')); + mockGetAllDocuments.mockResolvedValue({}); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('document-selector-error')).toBeTruthy(); + }); + + consoleWarnSpy.mockRestore(); + }); + + it('retry button reloads documents after an error', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + // First attempt fails, retry succeeds + mockLoadDocumentCatalog + .mockRejectedValueOnce(new Error('failure')) + .mockResolvedValueOnce(catalog); + mockGetAllDocuments + .mockResolvedValueOnce({}) + .mockResolvedValueOnce( + createAllDocuments([createDocumentEntry(passport)]), + ); + + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { getByTestId, queryByTestId } = render( + , + ); + + await waitFor(() => { + expect(getByTestId('document-selector-error')).toBeTruthy(); + }); + + fireEvent.press(getByTestId('document-selector-retry')); + + await waitFor(() => { + expect(queryByTestId('document-selector-error')).toBeNull(); + expect(getByTestId('document-selector-list')).toBeTruthy(); + expect(getByTestId('document-selector-item-doc-1')).toBeTruthy(); + }); + + consoleWarnSpy.mockRestore(); + }); + + it('shows an error when Continue fails to select the document', async () => { + const passport = createMetadata({ + id: 'doc-1', + documentType: 'us', + isRegistered: true, + }); + const catalog: DocumentCatalog = { + documents: [passport], + selectedDocumentId: 'doc-1', + }; + + mockLoadDocumentCatalog.mockResolvedValue(catalog); + mockGetAllDocuments.mockResolvedValue( + createAllDocuments([createDocumentEntry(passport)]), + ); + mockSetSelectedDocument.mockRejectedValue(new Error('Selection failed')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { getByTestId, getByText } = render( + , + ); + + await waitFor(() => { + expect(getByTestId('document-selector-continue').props.disabled).toBe( + false, + ); + }); + + fireEvent.press(getByTestId('document-selector-continue')); + + await waitFor(() => { + expect(getByTestId('document-selector-error')).toBeTruthy(); + }); + + expect( + getByText('Failed to select document. Please try again.'), + ).toBeTruthy(); + expect(mockNavigate).not.toHaveBeenCalledWith('Prove'); + + consoleErrorSpy.mockRestore(); + }); +});