agent feedback (#1744)

This commit is contained in:
Justin Hernandez
2026-02-12 17:35:54 -08:00
committed by GitHub
parent 2495b7af4d
commit eee830b7ea
9 changed files with 898 additions and 12 deletions

View File

@@ -137,6 +137,8 @@ const UnregisteredIdCard: FC<UnregisteredIdCardProps> = ({
justifyContent="center"
onPress={onRegisterPress}
pressStyle={{ opacity: 0.7 }}
accessibilityRole="button"
accessibilityLabel="Complete Registration"
>
<Text
fontFamily={dinot}

View File

@@ -81,11 +81,31 @@ export function usePendingKycRecovery() {
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
return;
}
// Navigation not ready yet - don't mark as attempted, allow retry
// Navigation not ready yet - poll until ready
console.log(
'[PendingKycRecovery] Navigation not ready, will retry recovery for:',
'[PendingKycRecovery] Navigation not ready, polling for readiness:',
processingWithDocument.userId,
);
const pollInterval = setInterval(() => {
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(

View File

@@ -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' }],
);
}
},
},

View File

@@ -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<PointsInfoScreenProps> = ({
}) => {
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

View File

@@ -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'}
</AbstractButton>
</YStack>
</View>

View File

@@ -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) => <mock-image {...props} />,
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) => (
<div {...props} onClick={onPress}>
{children}
</div>
);
const MockXStack = ({ children, ...props }: any) => (
<div {...props}>{children}</div>
);
const MockText = ({ children, ...props }: any) => (
<span {...props}>{children}</span>
);
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(<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />);
}).not.toThrow();
});
it('should display "UNREGISTERED ID" text', () => {
const { root } = render(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
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(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
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(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
// 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(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
// 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(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
// 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(
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
);
// 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);
});
});
});

View File

@@ -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<typeof navigationRef>;
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();
});
});

View File

@@ -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) => <div {...props}>{children}</div>,
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) => <div {...props}>{children}</div>,
}));
// 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) => (
<div {...props}>
<button onClick={onRemoveExpirationDateFlag}>
Remove Expiration Date Flag
</button>
</div>
),
DebugShortcutsSection: () => <div>DebugShortcuts</div>,
DevTogglesSection: () => <div>DevToggles</div>,
PushNotificationsSection: () => <div>PushNotifications</div>,
}));
jest.mock('@/screens/dev/components/ParameterSection', () => ({
ParameterSection: ({ children }: any) => <div>{children}</div>,
}));
jest.mock('@/screens/dev/components/LogLevelSelector', () => ({
LogLevelSelector: () => <div>LogLevelSelector</div>,
}));
jest.mock('@/screens/dev/components/ErrorInjectionSelector', () => ({
ErrorInjectionSelector: () => <div>ErrorInjectionSelector</div>,
}));
jest.mock('@/components/ErrorBoundary', () => ({
__esModule: true,
default: ({ children }: any) => <div>{children}</div>,
}));
// 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(<DevSettingsScreen />);
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(<DevSettingsScreen />);
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(<DevSettingsScreen />);
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(<DevSettingsScreen />);
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(<DevSettingsScreen />);
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(<DevSettingsScreen />);
const button = root.findByType('button');
button.props.onClick();
// User cancels - should not load or save anything
expect(mockLoadDocumentCatalog).not.toHaveBeenCalled();
expect(mockSaveDocumentCatalog).not.toHaveBeenCalled();
});
});

View File

@@ -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(<KYCVerifiedScreen />);
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(<KYCVerifiedScreen />);
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(<KYCVerifiedScreen />);
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(<KYCVerifiedScreen />);
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');
});
});
});
});