Add comprehensive error handling for API device attachment

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-31 18:29:26 -08:00
parent 71f194cc4d
commit 0c801c3b19
2 changed files with 173 additions and 0 deletions

View File

@ -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}`);
});
});
});

View File

@ -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';