WellNuo/__tests__/services/api-sensor-attachment-errors.test.ts
Sergei 30df915433 Add comprehensive API error handling for sensor attachment
Improved error handling in the attachDeviceToBeneficiary method with:

- Structured ApiResponse return type with detailed error codes
- User-friendly error messages for different failure scenarios:
  - DEPLOYMENT_NOT_FOUND: Beneficiary has no deployment configured
  - UNAUTHORIZED: Missing or expired authentication credentials
  - NOT_FOUND: Sensor or deployment not found (404)
  - SERVER_ERROR: Legacy API server error (500)
  - SERVICE_UNAVAILABLE: Service temporarily unavailable (503+)
  - LEGACY_API_ERROR: Error from Legacy API response body
  - NETWORK_ERROR: Network connectivity issues
  - EXCEPTION: Unexpected errors

- Enhanced error messages in setup-wifi.tsx to display API error details
- Fixed navigator.onLine check to work in test environment
- Added comprehensive test suite with 11 test cases covering all error scenarios

All tests passing (11/11).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 16:08:08 -08:00

387 lines
12 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=');
});
});
});