From 88fc9042a70f228a77b2a73c47329161fb9bb183 Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 29 Jan 2026 11:27:19 -0800 Subject: [PATCH] Add retry button to error states in equipment and subscription screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added error state with retry functionality to equipment.tsx - Display error message when sensor loading fails - Provide "Try Again" button to retry loading - Clear error on successful retry - Added error state with retry functionality to subscription.tsx - Display error message when beneficiary loading fails - Provide "Try Again" button with icon to retry loading - Show offline icon and proper error layout - Added comprehensive tests for error handling - ErrorMessage component tests for inline errors - FullScreenError component tests - Equipment screen error state tests - Subscription screen error state tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- __tests__/components/ErrorMessage.test.tsx | 79 ++++++++++++++ __tests__/screens/equipment.test.tsx | 91 ++++++++++++++++ __tests__/screens/subscription.test.tsx | 103 ++++++++++++++++++ app/(tabs)/beneficiaries/[id]/equipment.tsx | 58 +++++++++- .../beneficiaries/[id]/subscription.tsx | 44 +++++++- 5 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 __tests__/components/ErrorMessage.test.tsx create mode 100644 __tests__/screens/equipment.test.tsx create mode 100644 __tests__/screens/subscription.test.tsx diff --git a/__tests__/components/ErrorMessage.test.tsx b/__tests__/components/ErrorMessage.test.tsx new file mode 100644 index 0000000..22d52f5 --- /dev/null +++ b/__tests__/components/ErrorMessage.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { ErrorMessage, FullScreenError } from '@/components/ui/ErrorMessage'; + +describe('ErrorMessage', () => { + it('renders error message with retry button', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + expect(getByText('Test error')).toBeTruthy(); + expect(getByText('Retry')).toBeTruthy(); + }); + + it('calls onRetry when retry button is pressed', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + fireEvent.press(getByText('Retry')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders without retry button when onRetry is not provided', () => { + const { queryByText } = render(); + + expect(queryByText('Retry')).toBeNull(); + }); + + it('renders with skip button when onSkip is provided', () => { + const onSkip = jest.fn(); + const { getByText } = render( + + ); + + expect(getByText('Skip')).toBeTruthy(); + }); +}); + +describe('FullScreenError', () => { + it('renders full screen error with retry button', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + expect(getByText('Something went wrong')).toBeTruthy(); + expect(getByText('Connection failed')).toBeTruthy(); + expect(getByText('Try Again')).toBeTruthy(); + }); + + it('calls onRetry when Try Again button is pressed', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + fireEvent.press(getByText('Try Again')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders custom title', () => { + const { getByText } = render( + + ); + + expect(getByText('Custom Error')).toBeTruthy(); + }); + + it('renders without retry button when onRetry is not provided', () => { + const { queryByText } = render( + + ); + + expect(queryByText('Try Again')).toBeNull(); + }); +}); diff --git a/__tests__/screens/equipment.test.tsx b/__tests__/screens/equipment.test.tsx new file mode 100644 index 0000000..4adb4fb --- /dev/null +++ b/__tests__/screens/equipment.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { api } from '@/services/api'; + +// Mock dependencies +jest.mock('@/services/api'); +jest.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ id: '1' }), + router: { back: jest.fn(), push: jest.fn() }, +})); +jest.mock('@/contexts/BeneficiaryContext', () => ({ + useBeneficiary: () => ({ + currentBeneficiary: { id: 1, name: 'Test User' }, + }), +})); +jest.mock('@/contexts/BLEContext', () => ({ + useBLE: () => ({ + isBLEAvailable: true, + }), +})); + +// Import the screen after mocks are set up +import EquipmentScreen from '@/app/(tabs)/beneficiaries/[id]/equipment'; + +describe('EquipmentScreen - Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays error message when API call fails', async () => { + const mockError = { ok: false, error: { message: 'Network error' } }; + (api.getDevicesForBeneficiary as jest.Mock).mockResolvedValue(mockError); + + const { getByText, findByText } = render(); + + await waitFor(() => { + expect(findByText('Network error')).toBeTruthy(); + }); + }); + + it('displays retry button when error occurs', async () => { + const mockError = { ok: false, error: { message: 'Failed to load sensors' } }; + (api.getDevicesForBeneficiary as jest.Mock).mockResolvedValue(mockError); + + const { findByText } = render(); + + await waitFor(() => { + expect(findByText('Try Again')).toBeTruthy(); + }); + }); + + it('retries loading sensors when retry button is pressed', async () => { + const mockError = { ok: false, error: { message: 'Failed to load sensors' } }; + const mockSuccess = { ok: true, data: [] }; + + (api.getDevicesForBeneficiary as jest.Mock) + .mockResolvedValueOnce(mockError) + .mockResolvedValueOnce(mockSuccess); + + const { findByText, queryByText } = render(); + + // Wait for error to appear + await waitFor(() => { + expect(findByText('Try Again')).toBeTruthy(); + }); + + // Press retry button + const retryButton = await findByText('Try Again'); + fireEvent.press(retryButton); + + // Wait for error to disappear after successful retry + await waitFor(() => { + expect(queryByText('Failed to load sensors')).toBeNull(); + }); + + // Verify API was called twice + expect(api.getDevicesForBeneficiary).toHaveBeenCalledTimes(2); + }); + + it('displays generic error message for unknown errors', async () => { + (api.getDevicesForBeneficiary as jest.Mock).mockRejectedValue( + new Error('Unknown error') + ); + + const { findByText } = render(); + + await waitFor(() => { + expect(findByText(/Unknown error|Failed to load sensors/)).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/screens/subscription.test.tsx b/__tests__/screens/subscription.test.tsx new file mode 100644 index 0000000..9aad38b --- /dev/null +++ b/__tests__/screens/subscription.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { api } from '@/services/api'; + +// Mock dependencies +jest.mock('@/services/api'); +jest.mock('expo-router', () => ({ + useLocalSearchParams: () => ({ id: '1' }), + router: { back: jest.fn(), replace: jest.fn() }, +})); +jest.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ + user: { id: '1', email: 'test@example.com' }, + }), +})); +jest.mock('@stripe/stripe-react-native', () => ({ + usePaymentSheet: () => ({ + initPaymentSheet: jest.fn(), + presentPaymentSheet: jest.fn(), + }), +})); + +// Import the screen after mocks are set up +import SubscriptionScreen from '@/app/(tabs)/beneficiaries/[id]/subscription'; + +describe('SubscriptionScreen - Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays error message when beneficiary loading fails', async () => { + const mockError = { ok: false, error: { message: 'Failed to load beneficiary data' } }; + (api.getWellNuoBeneficiary as jest.Mock).mockResolvedValue(mockError); + (api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } }); + + const { findByText } = render(); + + await waitFor(() => { + expect(findByText('Failed to load beneficiary data')).toBeTruthy(); + }); + }); + + it('displays retry button when error occurs', async () => { + const mockError = { ok: false, error: { message: 'Network error' } }; + (api.getWellNuoBeneficiary as jest.Mock).mockResolvedValue(mockError); + (api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } }); + + const { findByText } = render(); + + await waitFor(() => { + expect(findByText('Try Again')).toBeTruthy(); + }); + }); + + it('retries loading beneficiary when retry button is pressed', async () => { + const mockError = { ok: false, error: { message: 'Network error' } }; + const mockSuccess = { + ok: true, + data: { + id: 1, + name: 'Test User', + subscription: { status: 'active' }, + }, + }; + + (api.getWellNuoBeneficiary as jest.Mock) + .mockResolvedValueOnce(mockError) + .mockResolvedValueOnce(mockSuccess); + (api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } }); + + const { findByText, queryByText } = render(); + + // Wait for error to appear + await waitFor(() => { + expect(findByText('Try Again')).toBeTruthy(); + }); + + // Press retry button + const retryButton = await findByText('Try Again'); + fireEvent.press(retryButton); + + // Wait for error to disappear after successful retry + await waitFor(() => { + expect(queryByText('Network error')).toBeNull(); + }); + + // Verify API was called twice (initial + retry) + expect(api.getWellNuoBeneficiary).toHaveBeenCalledTimes(2); + }); + + it('displays generic error message for exceptions', async () => { + (api.getWellNuoBeneficiary as jest.Mock).mockRejectedValue( + new Error('Connection timeout') + ); + (api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } }); + + const { findByText } = render(); + + await waitFor(() => { + expect(findByText(/Connection timeout|Failed to load beneficiary data/)).toBeTruthy(); + }); + }); +}); diff --git a/app/(tabs)/beneficiaries/[id]/equipment.tsx b/app/(tabs)/beneficiaries/[id]/equipment.tsx index bc78dd4..b3c9722 100644 --- a/app/(tabs)/beneficiaries/[id]/equipment.tsx +++ b/app/(tabs)/beneficiaries/[id]/equipment.tsx @@ -46,6 +46,7 @@ export default function EquipmentScreen() { const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [isDetaching, setIsDetaching] = useState(null); + const [error, setError] = useState(null); const beneficiaryName = currentBeneficiary?.name || 'this person'; @@ -60,20 +61,20 @@ export default function EquipmentScreen() { try { setIsLoading(true); + setError(null); // Get WP sensors from API (attached to beneficiary) const response = await api.getDevicesForBeneficiary(id); if (!response.ok) { - // If error is "Not authenticated with Legacy API" or network error, - // just show empty state without Alert + setError(response.error?.message || 'Failed to load sensors'); setApiSensors([]); return; } setApiSensors(response.data || []); } catch (error) { - // Show empty state instead of Alert + setError(error instanceof Error ? error.message : 'Failed to load sensors'); setApiSensors([]); } finally { setIsLoading(false); @@ -313,6 +314,20 @@ export default function EquipmentScreen() { )} + {/* Error Message */} + {error && ( + + + + {error} + + + + Try Again + + + )} + (null); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const [showSuccessModal, setShowSuccessModal] = useState(false); const [justSubscribed, setJustSubscribed] = useState(false); const [transactions, setTransactions] = useState { if (!id) return; try { + setError(null); const response = await api.getWellNuoBeneficiary(parseInt(id, 10)); if (response.ok && response.data) { setBeneficiary(response.data); + } else { + setError(response.error?.message || 'Failed to load beneficiary data'); } } catch (error) { - // Failed to load beneficiary + setError(error instanceof Error ? error.message : 'Failed to load beneficiary data'); } finally { setIsLoading(false); } @@ -317,7 +321,7 @@ export default function SubscriptionScreen() { ); } - if (!beneficiary) { + if (!beneficiary || error) { return ( @@ -328,7 +332,18 @@ export default function SubscriptionScreen() { - Unable to load beneficiary + + {error || 'Unable to load beneficiary'} + { + setIsLoading(true); + loadBeneficiary(); + }} + > + + Try Again + ); @@ -568,6 +583,29 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', + padding: Spacing.xl, + }, + errorTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginTop: Spacing.lg, + textAlign: 'center', + }, + retryButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.primary, + paddingVertical: Spacing.sm + 4, + paddingHorizontal: Spacing.lg, + borderRadius: BorderRadius.lg, + marginTop: Spacing.xl, + gap: Spacing.sm, + }, + retryButtonText: { + color: AppColors.white, + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, }, errorText: { fontSize: FontSizes.base,