mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
agent feedback (#1744)
This commit is contained in:
@@ -137,6 +137,8 @@ const UnregisteredIdCard: FC<UnregisteredIdCardProps> = ({
|
||||
justifyContent="center"
|
||||
onPress={onRegisterPress}
|
||||
pressStyle={{ opacity: 0.7 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Complete Registration"
|
||||
>
|
||||
<Text
|
||||
fontFamily={dinot}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }],
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
175
app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx
Normal file
175
app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
app/tests/src/hooks/usePendingKycRecovery.test.ts
Normal file
260
app/tests/src/hooks/usePendingKycRecovery.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
316
app/tests/src/screens/dev/DevSettingsScreen.test.tsx
Normal file
316
app/tests/src/screens/dev/DevSettingsScreen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user