From eee830b7ea09ac59122b1e999e2fd378225cd414 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 12 Feb 2026 17:35:54 -0800 Subject: [PATCH] agent feedback (#1744) --- .../homescreen/UnregisteredIdCard.tsx | 2 + app/src/hooks/usePendingKycRecovery.ts | 24 +- app/src/screens/dev/DevSettingsScreen.tsx | 37 +- app/src/screens/home/PointsInfoScreen.tsx | 7 +- app/src/screens/kyc/KYCVerifiedScreen.tsx | 14 +- .../homescreen/UnregisteredIdCard.test.tsx | 175 ++++++++++ .../src/hooks/usePendingKycRecovery.test.ts | 260 ++++++++++++++ .../screens/dev/DevSettingsScreen.test.tsx | 316 ++++++++++++++++++ .../screens/kyc/KYCVerifiedScreen.test.tsx | 75 +++++ 9 files changed, 898 insertions(+), 12 deletions(-) create mode 100644 app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx create mode 100644 app/tests/src/hooks/usePendingKycRecovery.test.ts create mode 100644 app/tests/src/screens/dev/DevSettingsScreen.test.tsx diff --git a/app/src/components/homescreen/UnregisteredIdCard.tsx b/app/src/components/homescreen/UnregisteredIdCard.tsx index e6785d93f..8fd6c22c2 100644 --- a/app/src/components/homescreen/UnregisteredIdCard.tsx +++ b/app/src/components/homescreen/UnregisteredIdCard.tsx @@ -137,6 +137,8 @@ const UnregisteredIdCard: FC = ({ justifyContent="center" onPress={onRegisterPress} pressStyle={{ opacity: 0.7 }} + accessibilityRole="button" + accessibilityLabel="Complete Registration" > { + if (navigationRef.isReady()) { + console.log( + '[PendingKycRecovery] Navigation ready, navigating for:', + processingWithDocument.userId, + ); + navigationRef.navigate('KYCVerified', { + documentId: processingWithDocument.documentId, + }); + hasAttemptedRecoveryRef.current.add(processingWithDocument.userId); + clearInterval(pollInterval); + } + }, 100); // Poll every 100ms + + // Cleanup polling on unmount or dependency change + return () => { + clearInterval(pollInterval); + }; } const firstPending = pendingVerifications.find( diff --git a/app/src/screens/dev/DevSettingsScreen.tsx b/app/src/screens/dev/DevSettingsScreen.tsx index 48f52f31d..88c36a2fc 100644 --- a/app/src/screens/dev/DevSettingsScreen.tsx +++ b/app/src/screens/dev/DevSettingsScreen.tsx @@ -64,16 +64,41 @@ const DevSettingsScreen: React.FC = () => { text: 'Remove', style: 'destructive', onPress: async () => { - const catalog = await loadDocumentCatalogDirectlyFromKeychain(); - const selectedDocumentId = catalog.selectedDocumentId; - const selectedDocument = catalog.documents.find( - document => document.id === selectedDocumentId, - ); + try { + const catalog = await loadDocumentCatalogDirectlyFromKeychain(); + const selectedDocumentId = catalog.selectedDocumentId; + const selectedDocument = catalog.documents.find( + document => document.id === selectedDocumentId, + ); + + if (!selectedDocument) { + Alert.alert( + 'No Document Selected', + 'Please select a document before removing the expiration date flag.', + [{ text: 'OK' }], + ); + return; + } - if (selectedDocument) { delete selectedDocument.hasExpirationDate; await saveDocumentCatalogDirectlyToKeychain(catalog); + + Alert.alert( + 'Success', + 'Expiration date flag removed successfully.', + [{ text: 'OK' }], + ); + } catch (error) { + console.error( + 'Failed to remove expiration date flag:', + error instanceof Error ? error.message : String(error), + ); + Alert.alert( + 'Error', + 'Failed to remove expiration date flag. Please try again.', + [{ text: 'OK' }], + ); } }, }, diff --git a/app/src/screens/home/PointsInfoScreen.tsx b/app/src/screens/home/PointsInfoScreen.tsx index cb3b13a44..460d723c8 100644 --- a/app/src/screens/home/PointsInfoScreen.tsx +++ b/app/src/screens/home/PointsInfoScreen.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Image, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView, Text, View, XStack, YStack } from 'tamagui'; @@ -96,7 +96,10 @@ const PointsInfoScreen: React.FC = ({ }) => { const { showNextButton, callbackId } = params || {}; const { left, right, bottom } = useSafeAreaInsets(); - const callbacks = callbackId ? getModalCallbacks(callbackId) : undefined; + const callbacks = useMemo( + () => (callbackId ? getModalCallbacks(callbackId) : undefined), + [callbackId], + ); const buttonPressedRef = useRef(false); // Handle button press: mark as pressed and call the callback diff --git a/app/src/screens/kyc/KYCVerifiedScreen.tsx b/app/src/screens/kyc/KYCVerifiedScreen.tsx index 6f84c8bbd..af8d6f046 100644 --- a/app/src/screens/kyc/KYCVerifiedScreen.tsx +++ b/app/src/screens/kyc/KYCVerifiedScreen.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React from 'react'; +import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { YStack } from 'tamagui'; @@ -33,11 +33,18 @@ const KYCVerifiedScreen: React.FC = () => { const selfClient = useSelfClient(); const { pendingVerifications, removePendingVerification } = usePendingKycStore(); + const [isLoading, setIsLoading] = useState(false); const documentId = route.params?.documentId; const handleGenerateProof = async () => { + // Prevent multiple concurrent proof generations + if (isLoading) { + return; + } + buttonTap(); + setIsLoading(true); try { if (!documentId) { @@ -83,6 +90,8 @@ const KYCVerifiedScreen: React.FC = () => { selfClient.emit(SdkEvents.DOCUMENT_OWNERSHIP_CONFIRMED, documentMetadata); } catch (err) { console.error('[KYCVerifiedScreen] Failed to trigger registration:', err); + } finally { + setIsLoading(false); } }; @@ -107,8 +116,9 @@ const KYCVerifiedScreen: React.FC = () => { bgColor={white} color={black} onPress={handleGenerateProof} + disabled={isLoading} > - Generate proof + {isLoading ? 'Generating...' : 'Generate proof'} diff --git a/app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx b/app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx new file mode 100644 index 000000000..061420267 --- /dev/null +++ b/app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx @@ -0,0 +1,175 @@ +// 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 React from 'react'; +import { render } from '@testing-library/react-native'; + +import UnregisteredIdCard from '@/components/homescreen/UnregisteredIdCard'; + +jest.mock('react-native', () => ({ + __esModule: true, + Image: ({ ...props }: any) => , + Platform: { OS: 'ios', select: jest.fn() }, + StyleSheet: { + create: (styles: any) => styles, + flatten: (style: any) => style, + }, +})); + +// Mock Tamagui components +jest.mock('tamagui', () => { + const MockYStack = ({ children, onPress, ...props }: any) => ( +
+ {children} +
+ ); + const MockXStack = ({ children, ...props }: any) => ( +
{children}
+ ); + const MockText = ({ children, ...props }: any) => ( + {children} + ); + + return { + __esModule: true, + Text: MockText, + XStack: MockXStack, + YStack: MockYStack, + }; +}); + +// Mock SVG +jest.mock('@/assets/images/self_logo_inactive.svg', () => 'SelfLogoInactive'); +jest.mock('@/assets/images/wave_pattern_body.png', () => 'WavePatternBody'); + +// Mock hooks +jest.mock('@/hooks/useCardDimensions', () => ({ + useCardDimensions: jest.fn(() => ({ + cardWidth: 300, + borderRadius: 10, + scale: 1, + headerHeight: 80, + figmaPadding: 16, + logoSize: 40, + headerGap: 10, + expandedAspectRatio: 1.5, + fontSize: { + header: 18, + subtitle: 12, + button: 16, + }, + })), +})); + +describe('UnregisteredIdCard', () => { + const mockOnRegisterPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render without crashing', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should display "UNREGISTERED ID" text', () => { + const { root } = render( + , + ); + + const unregisteredText = root.findAll( + node => node.type === 'span' && node.props.children === 'UNREGISTERED ID', + ); + expect(unregisteredText.length).toBeGreaterThan(0); + }); + + it('should display "Complete Registration" button text', () => { + const { root } = render( + , + ); + + const buttonText = root.findAll( + node => + node.type === 'span' && node.props.children === 'Complete Registration', + ); + expect(buttonText.length).toBeGreaterThan(0); + }); + + it('should call onRegisterPress when button is pressed', () => { + const { root } = render( + , + ); + + // Find the clickable YStack (button container) + const buttonContainers = root.findAll( + node => node.type === 'div' && node.props.onClick, + ); + + // Find the button with "Complete Registration" text + const registerButton = buttonContainers.find(container => { + const textNodes = container.findAll( + node => + node.type === 'span' && + node.props.children === 'Complete Registration', + ); + return textNodes.length > 0; + }); + + expect(registerButton).toBeTruthy(); + + // Simulate press by calling onClick directly + registerButton!.props.onClick(); + + expect(mockOnRegisterPress).toHaveBeenCalledTimes(1); + }); + + describe('Accessibility', () => { + it('should have button accessibility role', () => { + const { root } = render( + , + ); + + // Find the YStack with accessibilityRole="button" + const buttonWithRole = root.findAll( + node => + node.type === 'div' && node.props.accessibilityRole === 'button', + ); + + expect(buttonWithRole.length).toBeGreaterThan(0); + }); + + it('should have accessible label for screen readers', () => { + const { root } = render( + , + ); + + // Find the YStack with accessibilityLabel + const buttonWithLabel = root.findAll( + node => + node.type === 'div' && + node.props.accessibilityLabel === 'Complete Registration', + ); + + expect(buttonWithLabel.length).toBeGreaterThan(0); + }); + + it('should have both accessibility role and label on the same element', () => { + const { root } = render( + , + ); + + // Find the YStack with both properties + const accessibleButton = root.findAll( + node => + node.type === 'div' && + node.props.accessibilityRole === 'button' && + node.props.accessibilityLabel === 'Complete Registration', + ); + + expect(accessibleButton.length).toBe(1); + }); + }); +}); diff --git a/app/tests/src/hooks/usePendingKycRecovery.test.ts b/app/tests/src/hooks/usePendingKycRecovery.test.ts new file mode 100644 index 000000000..e6ece2e26 --- /dev/null +++ b/app/tests/src/hooks/usePendingKycRecovery.test.ts @@ -0,0 +1,260 @@ +// 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 { renderHook, waitFor } from '@testing-library/react-native'; + +import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery'; +import { navigationRef } from '@/navigation'; + +// Mock dependencies +jest.mock('@/hooks/useSumsubWebSocket', () => ({ + useSumsubWebSocket: jest.fn(() => ({ + subscribe: jest.fn(), + unsubscribeAll: jest.fn(), + })), +})); + +jest.mock('@/stores/pendingKycStore', () => ({ + usePendingKycStore: jest.fn(), +})); + +jest.mock('@/navigation', () => ({ + navigationRef: { + isReady: jest.fn(), + navigate: jest.fn(), + }, +})); + +const mockNavigationRef = navigationRef as jest.Mocked; + +describe('usePendingKycRecovery', () => { + const mockSubscribe = jest.fn(); + const mockUnsubscribeAll = jest.fn(); + const mockRemoveExpiredVerifications = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + + // Setup default mocks + const { useSumsubWebSocket } = jest.requireMock( + '@/hooks/useSumsubWebSocket', + ); + useSumsubWebSocket.mockReturnValue({ + subscribe: mockSubscribe, + unsubscribeAll: mockUnsubscribeAll, + }); + + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + mockNavigationRef.isReady.mockReturnValue(true); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should remove expired verifications on mount', () => { + renderHook(() => usePendingKycRecovery()); + + expect(mockRemoveExpiredVerifications).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe all on unmount', () => { + const { unmount } = renderHook(() => usePendingKycRecovery()); + + unmount(); + + expect(mockUnsubscribeAll).toHaveBeenCalledTimes(1); + }); + + it('should navigate to KYCVerified when processing verification exists and navigation is ready', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-123', + status: 'processing', + documentId: 'doc-456', + timeoutAt: Date.now() + 10000, + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + mockNavigationRef.isReady.mockReturnValue(true); + + renderHook(() => usePendingKycRecovery()); + + expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', { + documentId: 'doc-456', + }); + }); + + it('should poll for navigation readiness when not initially ready', async () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-123', + status: 'processing', + documentId: 'doc-456', + timeoutAt: Date.now() + 10000, + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + // Navigation not ready initially + mockNavigationRef.isReady.mockReturnValue(false); + + renderHook(() => usePendingKycRecovery()); + + // Should not navigate immediately + expect(mockNavigationRef.navigate).not.toHaveBeenCalled(); + + // Simulate navigation becoming ready after 300ms + jest.advanceTimersByTime(300); + mockNavigationRef.isReady.mockReturnValue(true); + + // Advance timers to trigger polling + jest.advanceTimersByTime(100); + + await waitFor(() => { + expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', { + documentId: 'doc-456', + }); + }); + }); + + it('should not attempt recovery for same userId twice', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + const verification = { + userId: 'user-123', + status: 'processing' as const, + documentId: 'doc-456', + timeoutAt: Date.now() + 10000, + }; + + usePendingKycStore.mockReturnValue({ + pendingVerifications: [verification], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + mockNavigationRef.isReady.mockReturnValue(true); + + const { rerender } = renderHook(() => usePendingKycRecovery()); + + expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1); + + // Rerender with same verification + rerender(); + + // Should not navigate again for same userId + expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to pending verification when no processing verification exists', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-789', + status: 'pending', + timeoutAt: Date.now() + 10000, + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + renderHook(() => usePendingKycRecovery()); + + expect(mockSubscribe).toHaveBeenCalledWith('user-789'); + }); + + it('should skip expired verifications', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-expired', + status: 'pending', + timeoutAt: Date.now() - 1000, // Expired + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + renderHook(() => usePendingKycRecovery()); + + // Should not subscribe to expired verification + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('should clean up polling interval on unmount', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-123', + status: 'processing', + documentId: 'doc-456', + timeoutAt: Date.now() + 10000, + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + mockNavigationRef.isReady.mockReturnValue(false); + + const { unmount } = renderHook(() => usePendingKycRecovery()); + + // Advance timers to ensure interval is created + jest.advanceTimersByTime(100); + + // Unmount should clear the interval + unmount(); + + // Advance timers further - navigate should not be called after unmount + mockNavigationRef.isReady.mockReturnValue(true); + jest.advanceTimersByTime(1000); + + expect(mockNavigationRef.navigate).not.toHaveBeenCalled(); + }); + + it('should prioritize processing verification over pending verification', () => { + const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore'); + usePendingKycStore.mockReturnValue({ + pendingVerifications: [ + { + userId: 'user-pending', + status: 'pending', + timeoutAt: Date.now() + 10000, + }, + { + userId: 'user-processing', + status: 'processing', + documentId: 'doc-789', + timeoutAt: Date.now() + 10000, + }, + ], + removeExpiredVerifications: mockRemoveExpiredVerifications, + }); + + mockNavigationRef.isReady.mockReturnValue(true); + + renderHook(() => usePendingKycRecovery()); + + // Should navigate to processing verification, not subscribe to pending + expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', { + documentId: 'doc-789', + }); + expect(mockSubscribe).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tests/src/screens/dev/DevSettingsScreen.test.tsx b/app/tests/src/screens/dev/DevSettingsScreen.test.tsx new file mode 100644 index 000000000..034b67a93 --- /dev/null +++ b/app/tests/src/screens/dev/DevSettingsScreen.test.tsx @@ -0,0 +1,316 @@ +// 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 React from 'react'; +import { Alert } from 'react-native'; +import { render, waitFor } from '@testing-library/react-native'; + +import DevSettingsScreen from '@/screens/dev/DevSettingsScreen'; + +// Mock Alert +jest.spyOn(Alert, 'alert'); + +// Mock react-native +jest.mock('react-native', () => ({ + __esModule: true, + Alert: { + alert: jest.fn(), + }, + ScrollView: ({ children, ...props }: any) =>
{children}
, + Platform: { OS: 'ios', select: jest.fn() }, + StyleSheet: { + create: (styles: any) => styles, + flatten: (style: any) => style, + }, +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: jest.fn(() => ({ bottom: 0 })), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(() => ({ navigate: jest.fn() })), +})); + +// Mock Tamagui +jest.mock('tamagui', () => ({ + YStack: ({ children, ...props }: any) =>
{children}
, +})); + +// Mock hooks and stores +jest.mock('@/stores/settingStore', () => ({ + useSettingStore: jest.fn(selector => { + const state = { + loggingSeverity: 'info', + setLoggingSeverity: jest.fn(), + useStrongBox: false, + setUseStrongBox: jest.fn(), + }; + return selector ? selector(state) : state; + }), +})); + +jest.mock('@/providers/passportDataProvider', () => ({ + loadDocumentCatalogDirectlyFromKeychain: jest.fn(), + saveDocumentCatalogDirectlyToKeychain: jest.fn(), +})); + +jest.mock('@/screens/dev/hooks/useDangerZoneActions', () => ({ + useDangerZoneActions: jest.fn(() => ({ + handleClearSecretsPress: jest.fn(), + handleClearDocumentCatalogPress: jest.fn(), + handleClearPointEventsPress: jest.fn(), + handleResetBackupStatePress: jest.fn(), + handleClearBackupEventsPress: jest.fn(), + handleClearPendingVerificationsPress: jest.fn(), + })), +})); + +jest.mock('@/screens/dev/hooks/useNotificationHandlers', () => ({ + useNotificationHandlers: jest.fn(() => ({ + hasNotificationPermission: false, + subscribedTopics: [], + handleTopicToggle: jest.fn(), + })), +})); + +// Mock sections +jest.mock('@/screens/dev/sections', () => ({ + DangerZoneSection: ({ onRemoveExpirationDateFlag, ...props }: any) => ( +
+ +
+ ), + DebugShortcutsSection: () =>
DebugShortcuts
, + DevTogglesSection: () =>
DevToggles
, + PushNotificationsSection: () =>
PushNotifications
, +})); + +jest.mock('@/screens/dev/components/ParameterSection', () => ({ + ParameterSection: ({ children }: any) =>
{children}
, +})); + +jest.mock('@/screens/dev/components/LogLevelSelector', () => ({ + LogLevelSelector: () =>
LogLevelSelector
, +})); + +jest.mock('@/screens/dev/components/ErrorInjectionSelector', () => ({ + ErrorInjectionSelector: () =>
ErrorInjectionSelector
, +})); + +jest.mock('@/components/ErrorBoundary', () => ({ + __esModule: true, + default: ({ children }: any) =>
{children}
, +})); + +// Mock icons +jest.mock('@/assets/icons/bug_icon.svg', () => 'BugIcon'); + +describe('DevSettingsScreen - handleRemoveExpirationDateFlagPress', () => { + let mockLoadDocumentCatalog: jest.Mock; + let mockSaveDocumentCatalog: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + const passportProvider = jest.requireMock( + '@/providers/passportDataProvider', + ); + mockLoadDocumentCatalog = + passportProvider.loadDocumentCatalogDirectlyFromKeychain; + mockSaveDocumentCatalog = + passportProvider.saveDocumentCatalogDirectlyToKeychain; + }); + + it('should show confirmation alert when Remove Expiration Date Flag is pressed', () => { + const { root } = render(); + + const button = root.findByType('button'); + expect(button).toBeTruthy(); + + button.props.onClick(); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Remove Expiration Date Flag', + 'Are you sure you want to remove the expiration date flag for the current (selected) document?.', + expect.arrayContaining([ + expect.objectContaining({ text: 'Cancel', style: 'cancel' }), + expect.objectContaining({ text: 'Remove', style: 'destructive' }), + ]), + ); + }); + + it('should successfully remove expiration date flag when document is selected', async () => { + const mockCatalog = { + selectedDocumentId: 'doc-123', + documents: [ + { + id: 'doc-123', + hasExpirationDate: true, + }, + ], + }; + + mockLoadDocumentCatalog.mockResolvedValue(mockCatalog); + mockSaveDocumentCatalog.mockResolvedValue(undefined); + + const { root } = render(); + + const button = root.findByType('button'); + button.props.onClick(); + + // Get the onPress callback from the alert + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove'); + + // Execute the remove action + await removeButton.onPress(); + + await waitFor(() => { + expect(mockLoadDocumentCatalog).toHaveBeenCalled(); + expect(mockSaveDocumentCatalog).toHaveBeenCalledWith({ + selectedDocumentId: 'doc-123', + documents: [ + { + id: 'doc-123', + // hasExpirationDate should be deleted + }, + ], + }); + }); + + // Success alert should be shown + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith( + 'Success', + 'Expiration date flag removed successfully.', + [{ text: 'OK' }], + ); + }); + }); + + it('should show error alert when no document is selected', async () => { + const mockCatalog = { + selectedDocumentId: 'non-existent-doc', + documents: [ + { + id: 'doc-123', + hasExpirationDate: true, + }, + ], + }; + + mockLoadDocumentCatalog.mockResolvedValue(mockCatalog); + + const { root } = render(); + + const button = root.findByType('button'); + button.props.onClick(); + + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove'); + + await removeButton.onPress(); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith( + 'No Document Selected', + 'Please select a document before removing the expiration date flag.', + [{ text: 'OK' }], + ); + }); + + // Should not attempt to save + expect(mockSaveDocumentCatalog).not.toHaveBeenCalled(); + }); + + it('should show error alert when loadDocumentCatalog fails', async () => { + const mockError = new Error('Failed to load catalog'); + mockLoadDocumentCatalog.mockRejectedValue(mockError); + + // Mock console.error to avoid test output clutter + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { root } = render(); + + const button = root.findByType('button'); + button.props.onClick(); + + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove'); + + await removeButton.onPress(); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to remove expiration date flag:', + 'Failed to load catalog', + ); + }); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Failed to remove expiration date flag. Please try again.', + [{ text: 'OK' }], + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('should show error alert when saveDocumentCatalog fails', async () => { + const mockCatalog = { + selectedDocumentId: 'doc-123', + documents: [ + { + id: 'doc-123', + hasExpirationDate: true, + }, + ], + }; + + mockLoadDocumentCatalog.mockResolvedValue(mockCatalog); + mockSaveDocumentCatalog.mockRejectedValue(new Error('Failed to save')); + + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const { root } = render(); + + const button = root.findByType('button'); + button.props.onClick(); + + const alertCall = (Alert.alert as jest.Mock).mock.calls[0]; + const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove'); + + await removeButton.onPress(); + + await waitFor(() => { + expect(Alert.alert).toHaveBeenCalledWith( + 'Error', + 'Failed to remove expiration date flag. Please try again.', + [{ text: 'OK' }], + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('should not call saveDocumentCatalog when user cancels', async () => { + const { root } = render(); + + const button = root.findByType('button'); + button.props.onClick(); + + // User cancels - should not load or save anything + expect(mockLoadDocumentCatalog).not.toHaveBeenCalled(); + expect(mockSaveDocumentCatalog).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx b/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx index 0ae5f38a5..7fa700abe 100644 --- a/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx +++ b/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx @@ -175,4 +175,79 @@ describe('KYCVerifiedScreen', () => { // Component should render without errors when documentId is provided expect(root).toBeTruthy(); }); + + describe('Loading state', () => { + it('should show "Generating..." text while loading', async () => { + const { root } = render(); + const button = root.findAllByType('button')[0]; + + // Initially shows "Generate proof" + expect(button.props.children).toBe('Generate proof'); + expect(button.props.disabled).toBeFalsy(); + + // Press the button + fireEvent.press(button); + + // Should show "Generating..." while loading + await waitFor(() => { + const updatedButton = root.findAllByType('button')[0]; + expect(updatedButton.props.children).toBe('Generating...'); + expect(updatedButton.props.disabled).toBe(true); + }); + }); + + it('should prevent multiple concurrent proof generations', async () => { + const { root } = render(); + const button = root.findAllByType('button')[0]; + + // Press the button multiple times rapidly + fireEvent.press(button); + fireEvent.press(button); + fireEvent.press(button); + + await waitFor(() => { + // Emit should only be called once + expect(mockEmit).toHaveBeenCalledTimes(1); + }); + }); + + it('should re-enable button after proof generation completes', async () => { + const { root } = render(); + const button = root.findAllByType('button')[0]; + + fireEvent.press(button); + + // Wait for async operations to complete + await waitFor(() => { + expect(mockEmit).toHaveBeenCalled(); + }); + + // Button should be re-enabled after completion + await waitFor(() => { + const updatedButton = root.findAllByType('button')[0]; + expect(updatedButton.props.disabled).toBeFalsy(); + expect(updatedButton.props.children).toBe('Generate proof'); + }); + }); + + it('should re-enable button after error', async () => { + // Mock an error in setSelectedDocument + const { setSelectedDocument } = jest.requireMock( + '@/providers/passportDataProvider', + ); + setSelectedDocument.mockRejectedValueOnce(new Error('Test error')); + + const { root } = render(); + const button = root.findAllByType('button')[0]; + + fireEvent.press(button); + + // Wait for error handling + await waitFor(() => { + const updatedButton = root.findAllByType('button')[0]; + expect(updatedButton.props.disabled).toBeFalsy(); + expect(updatedButton.props.children).toBe('Generate proof'); + }); + }); + }); });