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,