Files
rentall-app/backend/tests/unit/services/googleMapsService.test.js
2026-01-15 18:47:43 -05:00

946 lines
28 KiB
JavaScript

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