// Mock the Google Maps client const mockPlaceAutocomplete = jest.fn(); const mockPlaceDetails = jest.fn(); const mockGeocode = jest.fn(); jest.mock('@googlemaps/google-maps-services-js', () => ({ Client: jest.fn().mockImplementation(() => ({ placeAutocomplete: mockPlaceAutocomplete, placeDetails: mockPlaceDetails, geocode: mockGeocode })) })); const mockLoggerInfo = jest.fn(); const mockLoggerError = jest.fn(); jest.mock('../../../utils/logger', () => ({ info: mockLoggerInfo, error: mockLoggerError, warn: jest.fn(), withRequestId: jest.fn(() => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), })), })); describe('GoogleMapsService', () => { let service; beforeEach(() => { // Clear all mocks jest.clearAllMocks(); // Reset environment delete process.env.GOOGLE_MAPS_API_KEY; // Clear module cache to get fresh instance jest.resetModules(); }); describe('Constructor', () => { it('should initialize with API key and log success', () => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); expect(mockLoggerInfo).toHaveBeenCalledWith('Google Maps service initialized'); expect(service.isConfigured()).toBe(true); }); it('should log error when API key is not configured', () => { delete process.env.GOOGLE_MAPS_API_KEY; service = require('../../../services/googleMapsService'); expect(mockLoggerError).toHaveBeenCalledWith('Google Maps API key not configured in environment variables'); expect(service.isConfigured()).toBe(false); }); it('should initialize Google Maps Client', () => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; const { Client } = require('@googlemaps/google-maps-services-js'); service = require('../../../services/googleMapsService'); expect(Client).toHaveBeenCalledWith({}); }); }); describe('getPlacesAutocomplete', () => { beforeEach(() => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); }); describe('Input validation', () => { it('should throw error when API key is not configured', async () => { service.apiKey = null; await expect(service.getPlacesAutocomplete('test')).rejects.toThrow('Google Maps API key not configured'); }); it('should return empty predictions for empty input', async () => { const result = await service.getPlacesAutocomplete(''); expect(result).toEqual({ predictions: [] }); expect(mockPlaceAutocomplete).not.toHaveBeenCalled(); }); it('should return empty predictions for input less than 2 characters', async () => { const result = await service.getPlacesAutocomplete('a'); expect(result).toEqual({ predictions: [] }); expect(mockPlaceAutocomplete).not.toHaveBeenCalled(); }); it('should trim input and proceed with valid input', async () => { mockPlaceAutocomplete.mockResolvedValue({ data: { status: 'OK', predictions: [] } }); await service.getPlacesAutocomplete(' test '); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: expect.objectContaining({ input: 'test' }), timeout: 5000 }); }); }); describe('Parameters handling', () => { beforeEach(() => { mockPlaceAutocomplete.mockResolvedValue({ data: { status: 'OK', predictions: [] } }); }); it('should use default parameters', async () => { await service.getPlacesAutocomplete('test input'); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: { key: 'test-api-key', input: 'test input', types: 'address', language: 'en' }, timeout: 5000 }); }); it('should accept custom options', async () => { const options = { types: 'establishment', language: 'fr' }; await service.getPlacesAutocomplete('test input', options); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: { key: 'test-api-key', input: 'test input', types: 'establishment', language: 'fr' }, timeout: 5000 }); }); it('should include session token when provided', async () => { const options = { sessionToken: 'session-123' }; await service.getPlacesAutocomplete('test input', options); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: expect.objectContaining({ sessiontoken: 'session-123' }), timeout: 5000 }); }); it('should handle component restrictions', async () => { const options = { componentRestrictions: { country: 'us', administrative_area: 'CA' } }; await service.getPlacesAutocomplete('test input', options); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: expect.objectContaining({ components: 'country:us|administrative_area:CA' }), timeout: 5000 }); }); it('should merge additional options', async () => { const options = { radius: 1000, location: '40.7128,-74.0060' }; await service.getPlacesAutocomplete('test input', options); expect(mockPlaceAutocomplete).toHaveBeenCalledWith({ params: expect.objectContaining({ radius: 1000, location: '40.7128,-74.0060' }), timeout: 5000 }); }); }); describe('Successful responses', () => { it('should return formatted predictions on success', async () => { const mockResponse = { data: { status: 'OK', predictions: [ { place_id: 'ChIJ123', description: 'Test Location, City, State', types: ['establishment'], structured_formatting: { main_text: 'Test Location', secondary_text: 'City, State' } }, { place_id: 'ChIJ456', description: 'Another Place', types: ['locality'], structured_formatting: { main_text: 'Another Place' } } ] } }; mockPlaceAutocomplete.mockResolvedValue(mockResponse); const result = await service.getPlacesAutocomplete('test input'); expect(result).toEqual({ predictions: [ { placeId: 'ChIJ123', description: 'Test Location, City, State', types: ['establishment'], mainText: 'Test Location', secondaryText: 'City, State' }, { placeId: 'ChIJ456', description: 'Another Place', types: ['locality'], mainText: 'Another Place', secondaryText: '' } ] }); }); it('should handle predictions without secondary text', async () => { const mockResponse = { data: { status: 'OK', predictions: [ { place_id: 'ChIJ123', description: 'Test Location', types: ['establishment'], structured_formatting: { main_text: 'Test Location' } } ] } }; mockPlaceAutocomplete.mockResolvedValue(mockResponse); const result = await service.getPlacesAutocomplete('test input'); expect(result.predictions[0].secondaryText).toBe(''); }); }); describe('Error responses', () => { it('should handle API error responses', async () => { const mockResponse = { data: { status: 'ZERO_RESULTS', error_message: 'No results found' } }; mockPlaceAutocomplete.mockResolvedValue(mockResponse); const result = await service.getPlacesAutocomplete('test input'); expect(result).toEqual({ predictions: [], error: 'No results found for this query', status: 'ZERO_RESULTS' }); expect(mockLoggerError).toHaveBeenCalledWith( 'Places Autocomplete API error', expect.objectContaining({ status: 'ZERO_RESULTS', }) ); }); it('should handle unknown error status', async () => { const mockResponse = { data: { status: 'UNKNOWN_STATUS' } }; mockPlaceAutocomplete.mockResolvedValue(mockResponse); const result = await service.getPlacesAutocomplete('test input'); expect(result.error).toBe('Google Maps API error: UNKNOWN_STATUS'); }); it('should handle network errors', async () => { mockPlaceAutocomplete.mockRejectedValue(new Error('Network error')); await expect(service.getPlacesAutocomplete('test input')).rejects.toThrow('Failed to fetch place predictions'); expect(mockLoggerError).toHaveBeenCalledWith('Places Autocomplete service error', expect.objectContaining({ error: expect.any(Error) })); }); }); }); describe('getPlaceDetails', () => { beforeEach(() => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); }); describe('Input validation', () => { it('should throw error when API key is not configured', async () => { service.apiKey = null; await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API key not configured'); }); it('should throw error when placeId is not provided', async () => { await expect(service.getPlaceDetails()).rejects.toThrow('Place ID is required'); await expect(service.getPlaceDetails('')).rejects.toThrow('Place ID is required'); await expect(service.getPlaceDetails(null)).rejects.toThrow('Place ID is required'); }); }); describe('Parameters handling', () => { beforeEach(() => { mockPlaceDetails.mockResolvedValue({ data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: 'Test Address', address_components: [], geometry: { location: { lat: 40.7128, lng: -74.0060 } } } } }); }); it('should use default parameters', async () => { await service.getPlaceDetails('ChIJ123'); expect(mockPlaceDetails).toHaveBeenCalledWith({ params: { key: 'test-api-key', place_id: 'ChIJ123', fields: [ 'address_components', 'formatted_address', 'geometry', 'place_id' ], language: 'en' }, timeout: 5000 }); }); it('should accept custom language', async () => { await service.getPlaceDetails('ChIJ123', { language: 'fr' }); expect(mockPlaceDetails).toHaveBeenCalledWith({ params: expect.objectContaining({ language: 'fr' }), timeout: 5000 }); }); it('should include session token when provided', async () => { await service.getPlaceDetails('ChIJ123', { sessionToken: 'session-123' }); expect(mockPlaceDetails).toHaveBeenCalledWith({ params: expect.objectContaining({ sessiontoken: 'session-123' }), timeout: 5000 }); }); }); describe('Successful responses', () => { it('should return formatted place details', async () => { const mockResponse = { data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: '123 Test St, Test City, TC 12345, USA', address_components: [ { long_name: '123', short_name: '123', types: ['street_number'] }, { long_name: 'Test Street', short_name: 'Test St', types: ['route'] }, { long_name: 'Test City', short_name: 'Test City', types: ['locality', 'political'] }, { long_name: 'Test State', short_name: 'TS', types: ['administrative_area_level_1', 'political'] }, { long_name: '12345', short_name: '12345', types: ['postal_code'] }, { long_name: 'United States', short_name: 'US', types: ['country', 'political'] } ], geometry: { location: { lat: 40.7128, lng: -74.0060 } } } } }; mockPlaceDetails.mockResolvedValue(mockResponse); const result = await service.getPlaceDetails('ChIJ123'); expect(result).toEqual({ placeId: 'ChIJ123', formattedAddress: '123 Test St, Test City, TC 12345, USA', addressComponents: { streetNumber: '123', route: 'Test Street', locality: 'Test City', administrativeAreaLevel1: 'TS', administrativeAreaLevel1Long: 'Test State', postalCode: '12345', country: 'US' }, geometry: { latitude: 40.7128, longitude: -74.0060 } }); }); it('should handle place details without address components', async () => { const mockResponse = { data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: 'Test Address', geometry: { location: { lat: 40.7128, lng: -74.0060 } } } } }; mockPlaceDetails.mockResolvedValue(mockResponse); const result = await service.getPlaceDetails('ChIJ123'); expect(result.addressComponents).toEqual({}); }); it('should handle place details without geometry', async () => { const mockResponse = { data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: 'Test Address' } } }; mockPlaceDetails.mockResolvedValue(mockResponse); const result = await service.getPlaceDetails('ChIJ123'); expect(result.geometry).toEqual({ latitude: 0, longitude: 0 }); }); it('should handle partial geometry data', async () => { const mockResponse = { data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: 'Test Address', geometry: {} } } }; mockPlaceDetails.mockResolvedValue(mockResponse); const result = await service.getPlaceDetails('ChIJ123'); expect(result.geometry).toEqual({ latitude: 0, longitude: 0 }); }); }); describe('Error responses', () => { it('should handle API error responses', async () => { const mockResponse = { data: { status: 'NOT_FOUND', error_message: 'Place not found' } }; mockPlaceDetails.mockResolvedValue(mockResponse); await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('The specified place was not found'); expect(mockLoggerError).toHaveBeenCalledWith( 'Place Details API error', expect.objectContaining({ status: 'NOT_FOUND', }) ); }); it('should handle response without result', async () => { const mockResponse = { data: { status: 'OK' } }; mockPlaceDetails.mockResolvedValue(mockResponse); await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow('Google Maps API error: OK'); }); it('should handle network errors', async () => { const originalError = new Error('Network error'); mockPlaceDetails.mockRejectedValue(originalError); await expect(service.getPlaceDetails('ChIJ123')).rejects.toThrow(originalError); expect(mockLoggerError).toHaveBeenCalledWith('Place Details service error', expect.objectContaining({ error: expect.any(Error) })); }); }); }); describe('geocodeAddress', () => { beforeEach(() => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); }); describe('Input validation', () => { it('should throw error when API key is not configured', async () => { service.apiKey = null; await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Google Maps API key not configured'); }); it('should throw error when address is not provided', async () => { await expect(service.geocodeAddress()).rejects.toThrow('Address is required for geocoding'); await expect(service.geocodeAddress('')).rejects.toThrow('Address is required for geocoding'); await expect(service.geocodeAddress(' ')).rejects.toThrow('Address is required for geocoding'); }); }); describe('Parameters handling', () => { beforeEach(() => { mockGeocode.mockResolvedValue({ data: { status: 'OK', results: [ { formatted_address: 'Test Address', place_id: 'ChIJ123', geometry: { location: { lat: 40.7128, lng: -74.0060 } } } ] } }); }); it('should use default parameters', async () => { await service.geocodeAddress('123 Test St'); expect(mockGeocode).toHaveBeenCalledWith({ params: { key: 'test-api-key', address: '123 Test St', language: 'en' }, timeout: 5000 }); }); it('should trim address input', async () => { await service.geocodeAddress(' 123 Test St '); expect(mockGeocode).toHaveBeenCalledWith({ params: expect.objectContaining({ address: '123 Test St' }), timeout: 5000 }); }); it('should accept custom language', async () => { await service.geocodeAddress('123 Test St', { language: 'fr' }); expect(mockGeocode).toHaveBeenCalledWith({ params: expect.objectContaining({ language: 'fr' }), timeout: 5000 }); }); it('should handle component restrictions', async () => { const options = { componentRestrictions: { country: 'us', administrative_area: 'CA' } }; await service.geocodeAddress('123 Test St', options); expect(mockGeocode).toHaveBeenCalledWith({ params: expect.objectContaining({ components: 'country:us|administrative_area:CA' }), timeout: 5000 }); }); it('should handle bounds parameter', async () => { const options = { bounds: '40.7,-74.1|40.8,-73.9' }; await service.geocodeAddress('123 Test St', options); expect(mockGeocode).toHaveBeenCalledWith({ params: expect.objectContaining({ bounds: '40.7,-74.1|40.8,-73.9' }), timeout: 5000 }); }); }); describe('Successful responses', () => { it('should return geocoded location', async () => { const mockResponse = { data: { status: 'OK', results: [ { formatted_address: '123 Test St, Test City, TC 12345, USA', place_id: 'ChIJ123', geometry: { location: { lat: 40.7128, lng: -74.0060 } } } ] } }; mockGeocode.mockResolvedValue(mockResponse); const result = await service.geocodeAddress('123 Test St'); expect(result).toEqual({ latitude: 40.7128, longitude: -74.0060, formattedAddress: '123 Test St, Test City, TC 12345, USA', placeId: 'ChIJ123' }); }); it('should return first result when multiple results', async () => { const mockResponse = { data: { status: 'OK', results: [ { formatted_address: 'First Result', place_id: 'ChIJ123', geometry: { location: { lat: 40.7128, lng: -74.0060 } } }, { formatted_address: 'Second Result', place_id: 'ChIJ456', geometry: { location: { lat: 40.7129, lng: -74.0061 } } } ] } }; mockGeocode.mockResolvedValue(mockResponse); const result = await service.geocodeAddress('123 Test St'); expect(result.formattedAddress).toBe('First Result'); expect(result.placeId).toBe('ChIJ123'); }); }); describe('Error responses', () => { it('should handle API error responses', async () => { const mockResponse = { data: { status: 'ZERO_RESULTS', error_message: 'No results found' } }; mockGeocode.mockResolvedValue(mockResponse); const result = await service.geocodeAddress('123 Test St'); expect(result).toEqual({ error: 'No results found for this query', status: 'ZERO_RESULTS' }); expect(mockLoggerError).toHaveBeenCalledWith( 'Geocoding API error', expect.objectContaining({ status: 'ZERO_RESULTS', }) ); }); it('should handle empty results array', async () => { const mockResponse = { data: { status: 'OK', results: [] } }; mockGeocode.mockResolvedValue(mockResponse); const result = await service.geocodeAddress('123 Test St'); expect(result.error).toBe('Google Maps API error: OK'); }); it('should handle network errors', async () => { mockGeocode.mockRejectedValue(new Error('Network error')); await expect(service.geocodeAddress('123 Test St')).rejects.toThrow('Failed to geocode address'); expect(mockLoggerError).toHaveBeenCalledWith('Geocoding service error', expect.objectContaining({ error: expect.any(Error) })); }); }); }); describe('getErrorMessage', () => { beforeEach(() => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); }); it('should return correct error messages for known status codes', () => { expect(service.getErrorMessage('ZERO_RESULTS')).toBe('No results found for this query'); expect(service.getErrorMessage('OVER_QUERY_LIMIT')).toBe('API quota exceeded. Please try again later'); expect(service.getErrorMessage('REQUEST_DENIED')).toBe('API request denied. Check API key configuration'); expect(service.getErrorMessage('INVALID_REQUEST')).toBe('Invalid request parameters'); expect(service.getErrorMessage('UNKNOWN_ERROR')).toBe('Server error. Please try again'); expect(service.getErrorMessage('NOT_FOUND')).toBe('The specified place was not found'); }); it('should return generic error message for unknown status codes', () => { expect(service.getErrorMessage('UNKNOWN_STATUS')).toBe('Google Maps API error: UNKNOWN_STATUS'); expect(service.getErrorMessage('CUSTOM_ERROR')).toBe('Google Maps API error: CUSTOM_ERROR'); }); it('should handle null/undefined status', () => { expect(service.getErrorMessage(null)).toBe('Google Maps API error: null'); expect(service.getErrorMessage(undefined)).toBe('Google Maps API error: undefined'); }); }); describe('isConfigured', () => { it('should return true when API key is configured', () => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); expect(service.isConfigured()).toBe(true); }); it('should return false when API key is not configured', () => { delete process.env.GOOGLE_MAPS_API_KEY; service = require('../../../services/googleMapsService'); expect(service.isConfigured()).toBe(false); }); it('should return false when API key is empty string', () => { process.env.GOOGLE_MAPS_API_KEY = ''; service = require('../../../services/googleMapsService'); expect(service.isConfigured()).toBe(false); }); }); describe('Singleton pattern', () => { it('should return the same instance on multiple requires', () => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; const service1 = require('../../../services/googleMapsService'); const service2 = require('../../../services/googleMapsService'); expect(service1).toBe(service2); }); }); describe('Integration scenarios', () => { beforeEach(() => { process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; service = require('../../../services/googleMapsService'); }); it('should handle typical place search workflow', async () => { // Mock autocomplete response mockPlaceAutocomplete.mockResolvedValue({ data: { status: 'OK', predictions: [ { place_id: 'ChIJ123', description: 'Test Location', types: ['establishment'], structured_formatting: { main_text: 'Test Location', secondary_text: 'City, State' } } ] } }); // Mock place details response mockPlaceDetails.mockResolvedValue({ data: { status: 'OK', result: { place_id: 'ChIJ123', formatted_address: 'Test Location, City, State', address_components: [], geometry: { location: { lat: 40.7128, lng: -74.0060 } } } } }); // Step 1: Get autocomplete predictions const autocompleteResult = await service.getPlacesAutocomplete('test loc'); expect(autocompleteResult.predictions).toHaveLength(1); // Step 2: Get detailed place information const placeId = autocompleteResult.predictions[0].placeId; const detailsResult = await service.getPlaceDetails(placeId); expect(detailsResult.placeId).toBe('ChIJ123'); expect(detailsResult.geometry.latitude).toBe(40.7128); expect(detailsResult.geometry.longitude).toBe(-74.0060); }); it('should handle geocoding workflow', async () => { mockGeocode.mockResolvedValue({ data: { status: 'OK', results: [ { formatted_address: '123 Test St, Test City, TC 12345, USA', place_id: 'ChIJ123', geometry: { location: { lat: 40.7128, lng: -74.0060 } } } ] } }); const result = await service.geocodeAddress('123 Test St, Test City, TC'); expect(result.latitude).toBe(40.7128); expect(result.longitude).toBe(-74.0060); expect(result.formattedAddress).toBe('123 Test St, Test City, TC 12345, USA'); }); }); });