From 0c801c3b19f15a9e10e3fee3bb26c48011beae49 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 31 Jan 2026 18:29:26 -0800 Subject: [PATCH] Add comprehensive error handling for API device attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit 429 rate limit handling with RATE_LIMITED error code - Add 6 new test cases for edge scenarios: - 403 Forbidden handling - 429 Rate Limit handling - JSON parse error handling - WellNuo API failure when fetching beneficiary - Timeout error detection - MAC address uppercase normalization verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../api-sensor-attachment-errors.test.ts | 170 ++++++++++++++++++ services/api.ts | 3 + 2 files changed, 173 insertions(+) diff --git a/__tests__/services/api-sensor-attachment-errors.test.ts b/__tests__/services/api-sensor-attachment-errors.test.ts index 79d7e5c..4a6fe73 100644 --- a/__tests__/services/api-sensor-attachment-errors.test.ts +++ b/__tests__/services/api-sensor-attachment-errors.test.ts @@ -382,5 +382,175 @@ describe('API - Sensor Attachment Error Handling', () => { // Token will be URL encoded in the body, so just check it contains the token expect(bodyString).toContain('token='); }); + + it('should handle 403 Forbidden 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 403 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ message: 'Access denied' }) + }); + + 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/); + expect(result.error?.status).toBe(403); + }); + + it('should handle 429 Rate Limit 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 429 response from Legacy API + .mockResolvedValueOnce({ + ok: false, + status: 429, + json: async () => ({ message: 'Too many requests' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('RATE_LIMITED'); + expect(result.error?.message).toContain('Too many requests'); + expect(result.error?.status).toBe(429); + }); + + it('should handle JSON parse errors gracefully', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock response with invalid JSON + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { throw new SyntaxError('Unexpected token'); } + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('EXCEPTION'); + }); + + it('should handle WellNuo API failure when getting beneficiary', async () => { + // Mock failed beneficiary lookup from WellNuo API + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal error' }) + }); + + const result = await api.attachDeviceToBeneficiary( + mockBeneficiaryId, + mockWellId, + mockDeviceMac + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe('DEPLOYMENT_NOT_FOUND'); + expect(result.error?.status).toBe(404); + }); + + it('should handle timeout errors', async () => { + // Mock successful beneficiary lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: 123, + firstName: 'Maria', + deploymentId: mockDeploymentId + }) + }) + // Mock timeout error + .mockRejectedValueOnce( + new Error('network request failed: timeout') + ); + + 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 uppercase device MAC address in request', async () => { + const lowercaseMac = '142b2f81a14c'; + + // 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, + lowercaseMac + ); + + // Verify MAC is uppercased + const secondCall = (global.fetch as jest.Mock).mock.calls[1]; + const bodyString = secondCall[1].body; + expect(bodyString).toContain(`device_mac=${lowercaseMac.toUpperCase()}`); + expect(bodyString).not.toContain(`device_mac=${lowercaseMac}`); + }); }); }); diff --git a/services/api.ts b/services/api.ts index f09131f..61153bd 100644 --- a/services/api.ts +++ b/services/api.ts @@ -2048,6 +2048,9 @@ class ApiService { } else if (attachResponse.status === 404) { errorMessage = 'Sensor or deployment not found.'; errorCode = 'NOT_FOUND'; + } else if (attachResponse.status === 429) { + errorMessage = 'Too many requests. Please wait a moment and try again.'; + errorCode = 'RATE_LIMITED'; } else if (attachResponse.status === 500) { errorMessage = 'Server error. Please try again later.'; errorCode = 'SERVER_ERROR';