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>
200 lines
6.9 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|