diff --git a/__tests__/services/api-sensor-attachment-errors.test.ts b/__tests__/services/api-sensor-attachment-errors.test.ts new file mode 100644 index 0000000..79d7e5c --- /dev/null +++ b/__tests__/services/api-sensor-attachment-errors.test.ts @@ -0,0 +1,386 @@ +import { api } from '@/services/api'; +import * as SecureStore from 'expo-secure-store'; + +// Mock dependencies +jest.mock('expo-secure-store'); +jest.mock('expo-file-system'); + +// Mock fetch globally +global.fetch = jest.fn(); + +describe('API - Sensor Attachment Error Handling', () => { + const mockBeneficiaryId = '123'; + const mockWellId = 497; + const mockDeviceMac = '142B2F81A14C'; + const mockDeploymentId = 22; + // Use 'robster' to match DEMO_LEGACY_USER in api.ts + const mockUserName = 'robster'; + + // Create a mock JWT with expiration far in the future + const createMockJWT = () => { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = btoa(JSON.stringify({ + sub: mockUserName, + exp: Math.floor(Date.now() / 1000) + 3600, // expires in 1 hour + })); + const signature = 'mock-signature'; + return `${header}.${payload}.${signature}`; + }; + + const mockToken = createMockJWT(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset SecureStore mock to default implementation + // legacyAccessToken needs to be a valid JWT format with valid expiration + (SecureStore.getItemAsync as jest.Mock).mockImplementation((key: string) => { + if (key === 'accessToken') return Promise.resolve('mock-jwt-token'); + if (key === 'userId') return Promise.resolve('user-123'); + if (key === 'userEmail') return Promise.resolve('test@example.com'); + if (key === 'legacyAccessToken') return Promise.resolve(mockToken); + if (key === 'legacyUserName') return Promise.resolve(mockUserName); + if (key === 'legacyUserId') return Promise.resolve('legacy-user-123'); + return Promise.resolve(null); + }); + }); + + describe('attachDeviceToBeneficiary', () => { + it('should handle deployment not found error', async () => { + // Mock getDeploymentForBeneficiary to return beneficiary without deployment + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + lastName: 'Garcia', + // No deploymentId + }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('DEPLOYMENT_NOT_FOUND'); + expect(result.error?.message).toContain('Could not find beneficiary deployment'); + expect(result.error?.status).toBe(404); + }); + + it('should handle missing authentication credentials', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }); + + // Mock missing legacy credentials + (SecureStore.getItemAsync as jest.Mock).mockImplementation((key: string) => { + if (key === 'accessToken') return Promise.resolve('mock-jwt-token'); + // Return null for legacy credentials + return Promise.resolve(null); + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('UNAUTHORIZED'); + expect(result.error?.message).toContain('Not authenticated'); + expect(result.error?.status).toBe(401); + }); + + it('should handle 401 Unauthorized from Legacy API', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock 401 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: 'Invalid credentials' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('UNAUTHORIZED'); + expect(result.error?.message).toMatch(/Authentication expired|Not authenticated/); + expect(result.error?.status).toBe(401); + }); + + it('should handle 404 Not Found from Legacy API', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock 404 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ message: 'Device not found' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('NOT_FOUND'); + expect(result.error?.message).toContain('Sensor or deployment not found'); + expect(result.error?.status).toBe(404); + }); + + it('should handle 500 Server Error from Legacy API', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock 500 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('SERVER_ERROR'); + expect(result.error?.message).toContain('Server error'); + expect(result.error?.status).toBe(500); + }); + + it('should handle 503 Service Unavailable from Legacy API', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock 503 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ message: 'Service unavailable' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(result.error?.message).toContain('Service unavailable'); + expect(result.error?.status).toBe(503); + }); + + it('should handle Legacy API error status in response body', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock successful HTTP but error status in body + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: '400 Bad Request', + message: 'Invalid device MAC address format' + }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('LEGACY_API_ERROR'); + expect(result.error?.message).toBe('Invalid device MAC address format'); + }); + + it('should handle network errors', async () => { + // Mock successful beneficiary lookup first + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Then mock network error on Legacy API call + .mockRejectedValueOnce( + new Error('fetch failed') + ); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('NETWORK_ERROR'); + expect(result.error?.message).toContain('No internet connection'); + }); + + it('should handle unexpected exceptions', async () => { + // Mock successful beneficiary lookup first + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Then mock unexpected error + .mockRejectedValueOnce( + new Error('Unexpected error occurred') + ); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('EXCEPTION'); + expect(result.error?.message).toContain('unexpected error'); + }); + + it('should successfully attach device when all is well', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock successful device_form response + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: '200 OK', + message: 'Device attached successfully' + }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual({ success: true }); + expect(result.error).toBeUndefined(); + }); + + it('should send correct request parameters to Legacy API', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock successful device_form response + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: '200 OK' + }) + }); + + await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + // Verify the second fetch call (device_form) + const secondCall = (global.fetch as jest.Mock).mock.calls[1]; + expect(secondCall[0]).toBe('https://eluxnetworks.net/function/well-api/api'); + expect(secondCall[1].method).toBe('POST'); + expect(secondCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + + // Verify request body contains correct parameters + const bodyString = secondCall[1].body; + expect(bodyString).toContain('function=device_form'); + expect(bodyString).toContain(`well_id=${mockWellId}`); + expect(bodyString).toContain(`device_mac=${mockDeviceMac.toUpperCase()}`); + expect(bodyString).toContain(`deployment_id=${mockDeploymentId}`); + expect(bodyString).toContain(`user_name=${mockUserName}`); + // Token will be URL encoded in the body, so just check it contains the token + expect(bodyString).toContain('token='); + }); + }); +}); diff --git a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx index ef80c2d..dc80949 100644 --- a/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx +++ b/app/(tabs)/beneficiaries/[id]/setup-wifi.tsx @@ -271,8 +271,9 @@ export default function SetupWiFiScreen() { mac ); if (!attachResponse.ok) { - const errorDetail = attachResponse.error || 'Unknown API error'; - throw new Error(`Failed to register sensor: ${errorDetail}`); + // Use the error message from the API response + const errorMessage = attachResponse.error?.message || 'Failed to register sensor'; + throw new Error(errorMessage); } } updateSensorStep(deviceId, 'attach', 'completed'); diff --git a/services/api.ts b/services/api.ts index 95be0d8..5051fd6 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1935,19 +1935,33 @@ class ApiService { beneficiaryId: string, wellId: number, deviceMac: string - ) { + ): Promise> { try { // Get deployment ID for beneficiary const deploymentResponse = await this.getDeploymentForBeneficiary(beneficiaryId); if (!deploymentResponse.ok) { - throw new Error(deploymentResponse.error.message); + return { + ok: false, + error: { + message: 'Could not find beneficiary deployment', + code: 'DEPLOYMENT_NOT_FOUND', + status: 404, + } + }; } const deploymentId = deploymentResponse.data; const creds = await this.getLegacyCredentials(); if (!creds) { - throw new Error('Not authenticated with Legacy API'); + return { + ok: false, + error: { + message: 'Not authenticated. Please log in again.', + code: 'UNAUTHORIZED', + status: 401, + } + }; } // Use device_form to attach device to deployment @@ -1968,18 +1982,66 @@ class ApiService { }); if (!attachResponse.ok) { - throw new Error(`Failed to attach device: HTTP ${attachResponse.status}`); + // Provide more specific error messages based on HTTP status + let errorMessage = 'Could not register sensor. Please try again.'; + let errorCode = 'API_ERROR'; + + if (attachResponse.status === 401 || attachResponse.status === 403) { + errorMessage = 'Authentication expired. Please log in again.'; + errorCode = 'UNAUTHORIZED'; + } else if (attachResponse.status === 404) { + errorMessage = 'Sensor or deployment not found.'; + errorCode = 'NOT_FOUND'; + } else if (attachResponse.status === 500) { + errorMessage = 'Server error. Please try again later.'; + errorCode = 'SERVER_ERROR'; + } else if (attachResponse.status >= 500) { + errorMessage = 'Service unavailable. Please check your internet connection.'; + errorCode = 'SERVICE_UNAVAILABLE'; + } + + return { + ok: false, + error: { + message: errorMessage, + code: errorCode, + status: attachResponse.status, + } + }; } const data = await attachResponse.json(); if (data.status !== '200 OK') { - throw new Error(data.message || `Legacy API error: ${data.status}`); + // Parse Legacy API error response + const errorMessage = data.message || 'Failed to register sensor'; + + return { + ok: false, + error: { + message: errorMessage, + code: 'LEGACY_API_ERROR', + status: attachResponse.status, + } + }; } - return { ok: true }; + return { ok: true, data: { success: true } }; } catch (error: any) { - return { ok: false, error: error.message }; + // Handle network errors and unexpected exceptions + const isNetworkError = error.message?.includes('network') || + error.message?.includes('fetch') || + (typeof navigator !== 'undefined' && !navigator.onLine); + + return { + ok: false, + error: { + message: isNetworkError + ? 'No internet connection. Please check your network.' + : 'An unexpected error occurred. Please try again.', + code: isNetworkError ? 'NETWORK_ERROR' : 'EXCEPTION', + } + }; } }