WellNuo/__tests__/services/api-sensor-attachment-errors.test.ts
Sergei 0c801c3b19 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>
2026-01-31 18:29:26 -08:00

557 lines
17 KiB
TypeScript

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