- 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>
557 lines
17 KiB
TypeScript
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}`);
|
|
});
|
|
});
|
|
});
|