WellNuo/__tests__/offline/offlineAwareApi.test.ts
Sergei 91e677178e Add offline mode graceful degradation
Implements comprehensive offline handling for API-first architecture:

Network Detection:
- Real-time connectivity monitoring via @react-native-community/netinfo
- useNetworkStatus hook for React components
- Utility functions: getNetworkStatus(), isOnline()
- Retry logic with exponential backoff

Offline-Aware API Layer:
- Wraps all API methods with network detection
- User-friendly error messages for offline states
- Automatic retries for read operations
- Custom offline messages for write operations

UI Components:
- OfflineBanner: Animated banner at top/bottom
- InlineOfflineBanner: Non-animated inline version
- Auto-shows/hides based on network status

Data Fetching Hooks:
- useOfflineAwareData: Hook for data fetching with offline handling
- useOfflineAwareMutation: Hook for create/update/delete operations
- Auto-refetch when network returns
- Optional polling support

Error Handling:
- Consistent error messages across app
- Network error detection
- Retry functionality with user feedback

Tests:
- Network status detection tests
- Offline-aware API wrapper tests
- 23 passing tests with full coverage

Documentation:
- Complete offline mode guide (docs/OFFLINE_MODE.md)
- Usage examples (components/examples/OfflineAwareExample.tsx)
- Best practices and troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 16:49:15 -08:00

200 lines
6.9 KiB
TypeScript

/**
* Tests for Offline-Aware API Wrapper
*/
import { api } from '@/services/api';
import {
withOfflineCheck,
isNetworkError,
getNetworkErrorMessage,
NETWORK_ERROR_MESSAGES,
NETWORK_ERROR_CODES,
offlineAwareApi,
} from '@/services/offlineAwareApi';
import * as networkStatus from '@/utils/networkStatus';
// Mock network status utilities
jest.mock('@/utils/networkStatus', () => ({
isOnline: jest.fn(),
retryWithBackoff: jest.fn((fn) => fn()),
DEFAULT_RETRY_CONFIG: {
maxAttempts: 3,
delayMs: 1000,
backoffMultiplier: 2,
},
}));
// Mock API service
jest.mock('@/services/api', () => ({
api: {
getAllBeneficiaries: jest.fn(),
createBeneficiary: jest.fn(),
},
}));
describe('Offline-Aware API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('isNetworkError', () => {
it('should identify network error by code', () => {
expect(isNetworkError({ message: 'Test', code: 'NETWORK_ERROR' })).toBe(true);
expect(isNetworkError({ message: 'Test', code: 'NETWORK_OFFLINE' })).toBe(true);
expect(isNetworkError({ message: 'Test', code: 'NETWORK_TIMEOUT' })).toBe(true);
});
it('should identify network error by message', () => {
expect(isNetworkError({ message: 'Network connection lost' })).toBe(true);
expect(isNetworkError({ message: 'Request timeout' })).toBe(true);
expect(isNetworkError({ message: 'Fetch failed' })).toBe(true);
expect(isNetworkError({ message: 'You are offline' })).toBe(true);
});
it('should return false for non-network errors', () => {
expect(isNetworkError({ message: 'Invalid input', code: 'VALIDATION_ERROR' })).toBe(false);
expect(isNetworkError({ message: 'Not found', code: 'NOT_FOUND' })).toBe(false);
});
it('should handle null/undefined errors', () => {
expect(isNetworkError(null as any)).toBe(false);
expect(isNetworkError(undefined as any)).toBe(false);
expect(isNetworkError({} as any)).toBe(false);
});
});
describe('getNetworkErrorMessage', () => {
it('should return specific message for offline error', () => {
const error = { message: 'Test', code: 'NETWORK_OFFLINE' };
expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.OFFLINE);
});
it('should return specific message for timeout error', () => {
const error = { message: 'Test', code: 'NETWORK_TIMEOUT' };
expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.TIMEOUT);
});
it('should return generic message for network error without specific code', () => {
const error = { message: 'Network failed', code: 'NETWORK_ERROR' };
expect(getNetworkErrorMessage(error)).toBe(NETWORK_ERROR_MESSAGES.GENERIC);
});
it('should return original message for non-network errors', () => {
const error = { message: 'Validation failed', code: 'VALIDATION_ERROR' };
expect(getNetworkErrorMessage(error)).toBe('Validation failed');
});
});
describe('withOfflineCheck', () => {
it('should return offline error when not online', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(false);
const result = await withOfflineCheck(() => Promise.resolve({ ok: true, data: 'test' }));
expect(result.ok).toBe(false);
expect(result.error?.code).toBe(NETWORK_ERROR_CODES.OFFLINE);
expect(result.error?.message).toBe(NETWORK_ERROR_MESSAGES.OFFLINE);
});
it('should use custom offline message', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(false);
const customMessage = 'Custom offline message';
const result = await withOfflineCheck(
() => Promise.resolve({ ok: true, data: 'test' }),
{ offlineMessage: customMessage }
);
expect(result.ok).toBe(false);
expect(result.error?.message).toBe(customMessage);
});
it('should execute API call when online', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(true);
const mockResponse = { ok: true, data: { id: 1, name: 'Test' } };
const apiCall = jest.fn().mockResolvedValue(mockResponse);
const result = await withOfflineCheck(apiCall);
expect(apiCall).toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it('should retry when retry option is enabled', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(true);
const mockResponse = { ok: true, data: 'test' };
const apiCall = jest.fn().mockResolvedValue(mockResponse);
await withOfflineCheck(apiCall, { retry: true });
expect(networkStatus.retryWithBackoff).toHaveBeenCalledWith(apiCall, expect.any(Object));
});
it('should convert exceptions to ApiResponse format', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(true);
const apiCall = jest.fn().mockRejectedValue(new Error('Something went wrong'));
const result = await withOfflineCheck(apiCall);
expect(result.ok).toBe(false);
expect(result.error?.message).toBe('Something went wrong');
expect(result.error?.code).toBe('NETWORK_ERROR');
});
});
describe('offlineAwareApi', () => {
beforeEach(() => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(true);
});
it('should wrap getAllBeneficiaries with offline check', async () => {
const mockData = [{ id: 1, name: 'Test' }];
(api.getAllBeneficiaries as jest.Mock).mockResolvedValue({
ok: true,
data: mockData,
});
const result = await offlineAwareApi.getAllBeneficiaries();
expect(api.getAllBeneficiaries).toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.data).toEqual(mockData);
});
it('should wrap createBeneficiary with offline check', async () => {
(api.createBeneficiary as jest.Mock).mockResolvedValue({
ok: true,
data: { id: 2, name: 'New Beneficiary' },
});
const result = await offlineAwareApi.createBeneficiary({ name: 'New Beneficiary' });
expect(api.createBeneficiary).toHaveBeenCalledWith({ name: 'New Beneficiary' });
expect(result.ok).toBe(true);
});
it('should return offline error when offline', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(false);
const result = await offlineAwareApi.getAllBeneficiaries();
expect(result.ok).toBe(false);
expect(result.error?.code).toBe(NETWORK_ERROR_CODES.OFFLINE);
expect(api.getAllBeneficiaries).not.toHaveBeenCalled();
});
it('should use custom offline messages for write operations', async () => {
(networkStatus.isOnline as jest.Mock).mockResolvedValue(false);
const result = await offlineAwareApi.createBeneficiary({ name: 'Test' });
expect(result.ok).toBe(false);
expect(result.error?.message).toContain('offline');
expect(result.error?.message.toLowerCase()).toContain('cannot');
});
});
});