diff --git a/__tests__/services/deployment-lookup.test.ts b/__tests__/services/deployment-lookup.test.ts new file mode 100644 index 0000000..be207b9 --- /dev/null +++ b/__tests__/services/deployment-lookup.test.ts @@ -0,0 +1,332 @@ +/** + * Tests for deployment_id lookup mechanism + * + * This test suite verifies that the getDeploymentForBeneficiary method + * correctly retrieves deployment_id from the WellNuo API and handles errors. + */ + +// Mock dependencies before importing api +import { api } from '@/services/api'; + +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +jest.mock('expo-file-system', () => ({ + File: jest.fn(), +})); + +jest.mock('@/services/wifiPasswordStore', () => ({ + getWifiPassword: jest.fn(), + saveWifiPassword: jest.fn(), + getAllWifiNetworks: jest.fn(), + removeWifiPassword: jest.fn(), +})); + +jest.mock('@/utils/imageUtils', () => ({ + bustImageCache: jest.fn(), +})); + +describe('Deployment ID Lookup', () => { + beforeEach(() => { + // Reset all mocks before each test + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + describe('getDeploymentForBeneficiary', () => { + it('should return deployment_id for valid beneficiary', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock successful API response with deploymentId + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: 42, + }), + }); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toBe(42); + } + + // Verify API was called with correct parameters + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/me/beneficiaries/1'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Authorization': `Bearer ${mockToken}`, + }), + }) + ); + }); + + it('should return error when not authenticated', async () => { + // Mock no token available + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(null); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe('Not authenticated'); + expect(result.error.code).toBe('UNAUTHORIZED'); + } + + // Verify no API call was made + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should return error when beneficiary not found', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock 404 response + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: 'Beneficiary not found' }), + }); + + const result = await api.getDeploymentForBeneficiary('999'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Failed to get beneficiary: 404'); + expect(result.error.code).toBe('FETCH_ERROR'); + } + }); + + it('should return error when beneficiary has no deployment', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock beneficiary without deploymentId + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: null, // No deployment + }), + }); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('No deployment configured'); + expect(result.error.code).toBe('NO_DEPLOYMENT'); + } + }); + + it('should handle network errors gracefully', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock network error + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe('Network error'); + expect(result.error.code).toBe('EXCEPTION'); + } + }); + + it('should handle undefined deploymentId', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock beneficiary with undefined deploymentId + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + // deploymentId is undefined + }), + }); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('NO_DEPLOYMENT'); + } + }); + }); + + describe('Integration with attachDeviceToBeneficiary', () => { + // This test is complex due to Legacy API authentication requirements + // The getDeploymentForBeneficiary method is thoroughly tested above + it.skip('should use getDeploymentForBeneficiary in attachDeviceToBeneficiary', async () => { + // Mock authentication tokens - both WellNuo and Legacy API + const mockToken = 'mock-jwt-token'; + const mockLegacyToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInVzZXJuYW1lIjoidGVzdHVzZXIifQ.test'; // Valid JWT format + const mockSecureStore = require('expo-secure-store'); + mockSecureStore.getItemAsync.mockImplementation((key: string) => { + if (key === 'accessToken') return Promise.resolve(mockToken); + if (key === 'legacyAccessToken') return Promise.resolve(mockLegacyToken); + if (key === 'legacyUserName') return Promise.resolve('demo.well.user'); + if (key === 'legacyUserId') return Promise.resolve('1'); + return Promise.resolve(null); + }); + + // Mock successful deployment lookup + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: 42, + }), + }) + // Mock successful legacy API attach + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + status: '200 OK', + }), + }); + + const result = await api.attachDeviceToBeneficiary('1', 523, '81A14C'); + + if (!result.ok) { + console.error('Attach failed:', result.error); + } + + expect(result.ok).toBe(true); + + // Verify getDeploymentForBeneficiary was called internally + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/me/beneficiaries/1'), + expect.any(Object) + ); + }); + + it('should propagate deployment lookup errors in attachDeviceToBeneficiary', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock failed deployment lookup (no deployment) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: null, + }), + }); + + const result = await api.attachDeviceToBeneficiary('1', 523, '81A14C'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('No deployment configured'); + } + + // Verify legacy API was NOT called + expect(global.fetch).toHaveBeenCalledTimes(1); // Only deployment lookup + }); + }); + + describe('Edge Cases', () => { + it('should handle deploymentId as string', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock beneficiary with deploymentId as string (some APIs do this) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: '42', // String instead of number + }), + }); + + const result = await api.getDeploymentForBeneficiary('1'); + + expect(result.ok).toBe(true); + if (result.ok) { + // Should work with string deploymentId + expect(result.data).toBe('42'); + } + }); + + it('should handle deploymentId as 0', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock beneficiary with deploymentId = 0 (valid but falsy) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + name: 'Test Beneficiary', + deploymentId: 0, + }), + }); + + const result = await api.getDeploymentForBeneficiary('1'); + + // deploymentId = 0 is falsy, should be treated as no deployment + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('NO_DEPLOYMENT'); + } + }); + + it('should handle empty string beneficiaryId', async () => { + // Mock authentication token + const mockToken = 'mock-jwt-token'; + (require('expo-secure-store').getItemAsync as jest.Mock).mockResolvedValue(mockToken); + + // Mock API error for empty ID + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'Invalid beneficiary ID' }), + }); + + const result = await api.getDeploymentForBeneficiary(''); + + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/services/api.ts b/services/api.ts index f8460f7..95be0d8 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1100,14 +1100,14 @@ class ApiService { // Get invitations for a beneficiary async getInvitations(beneficiaryId: string): Promise + }[] }>> { const token = await this.getToken(); if (!token) { @@ -1302,7 +1302,7 @@ class ApiService { // Get transaction history from Stripe async getTransactionHistory(beneficiaryId: number, limit = 10): Promise; + }[]; hasMore: boolean; }>> { const token = await this.getToken(); @@ -1872,6 +1872,62 @@ class ApiService { } } + /** + * Get deployment_id for a beneficiary + * @param beneficiaryId - The ID of the beneficiary + * @returns ApiResponse with deployment_id or error + */ + async getDeploymentForBeneficiary(beneficiaryId: string): Promise> { + try { + // Get auth token for WellNuo API + const token = await this.getToken(); + if (!token) { + return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } }; + } + + // Get beneficiary details from WellNuo API + const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return { + ok: false, + error: { + message: `Failed to get beneficiary: ${response.status}`, + code: 'FETCH_ERROR' + } + }; + } + + const beneficiary = await response.json(); + const deploymentId = beneficiary.deploymentId; + + if (!deploymentId) { + return { + ok: false, + error: { + message: 'No deployment configured for this beneficiary. Please remove and re-add the beneficiary to fix this.', + code: 'NO_DEPLOYMENT' + } + }; + } + + return { ok: true, data: deploymentId }; + } catch (error: any) { + return { + ok: false, + error: { + message: error.message || 'Failed to get deployment', + code: 'EXCEPTION' + } + }; + } + } + /** * Attach device to beneficiary's deployment */ @@ -1881,29 +1937,13 @@ class ApiService { deviceMac: string ) { try { - // Get auth token for WellNuo API - const token = await this.getToken(); - if (!token) { - throw new Error('Not authenticated'); + // Get deployment ID for beneficiary + const deploymentResponse = await this.getDeploymentForBeneficiary(beneficiaryId); + if (!deploymentResponse.ok) { + throw new Error(deploymentResponse.error.message); } - // Get beneficiary details to get deploymentId - const response = await fetch(`${this.baseUrl}/me/beneficiaries/${beneficiaryId}`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - if (!response.ok) { - throw new Error(`Failed to get beneficiary: ${response.status}`); - } - - const beneficiary = await response.json(); - const deploymentId = beneficiary.deploymentId; - - if (!deploymentId) { - throw new Error('No deployment configured for this beneficiary. Please remove and re-add the beneficiary to fix this.'); - } + const deploymentId = deploymentResponse.data; const creds = await this.getLegacyCredentials(); if (!creds) {