const request = require('supertest'); const express = require('express'); // Mock dependencies jest.mock('../../../services/googleMapsService', () => ({ getPlacesAutocomplete: jest.fn(), getPlaceDetails: jest.fn(), geocodeAddress: jest.fn(), isConfigured: jest.fn() })); // Mock auth middleware jest.mock('../../../middleware/auth', () => ({ authenticateToken: (req, res, next) => { if (req.headers.authorization) { req.user = { id: 1 }; next(); } else { res.status(401).json({ error: 'No token provided' }); } } })); // Mock rate limiter middleware jest.mock('../../../middleware/rateLimiter', () => ({ burstProtection: (req, res, next) => next(), placesAutocomplete: (req, res, next) => next(), placeDetails: (req, res, next) => next(), geocoding: (req, res, next) => next() })); const googleMapsService = require('../../../services/googleMapsService'); const mapsRoutes = require('../../../routes/maps'); // Set up Express app for testing const app = express(); app.use(express.json()); app.use('/maps', mapsRoutes); describe('Maps Routes', () => { let consoleSpy, consoleErrorSpy, consoleLogSpy; beforeEach(() => { jest.clearAllMocks(); // Set up console spies consoleSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); }); afterEach(() => { consoleSpy.mockRestore(); consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); }); describe('Input Validation Middleware', () => { it('should trim and validate input length', async () => { const longInput = 'a'.repeat(501); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: longInput }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Input too long' }); }); it('should validate place ID format', async () => { const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'invalid@place#id!' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Invalid place ID format' }); }); it('should validate address length', async () => { const longAddress = 'a'.repeat(501); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: longAddress }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Address too long' }); }); it('should allow valid place ID format', async () => { googleMapsService.getPlaceDetails.mockResolvedValue({ result: { name: 'Test Place' } }); const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'ChIJ123abc_DEF' }); expect(response.status).toBe(200); }); it('should trim whitespace from inputs', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: ' test input ' }); expect(response.status).toBe(200); expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith( 'test input', expect.any(Object) ); }); }); describe('Error Handling Middleware', () => { it('should handle API key configuration errors', async () => { const configError = new Error('API key not configured'); googleMapsService.getPlacesAutocomplete.mockRejectedValue(configError); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(503); expect(response.body).toEqual({ error: 'Maps service temporarily unavailable', details: 'Configuration issue' }); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Maps service error:', 'API key not configured' ); }); it('should handle quota exceeded errors', async () => { const quotaError = new Error('quota exceeded'); googleMapsService.getPlacesAutocomplete.mockRejectedValue(quotaError); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(429); expect(response.body).toEqual({ error: 'Service temporarily unavailable due to high demand', details: 'Please try again later' }); }); it('should handle generic service errors', async () => { const serviceError = new Error('Network timeout'); googleMapsService.getPlacesAutocomplete.mockRejectedValue(serviceError); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Failed to process request', details: 'Network timeout' }); }); }); describe('POST /places/autocomplete', () => { const mockPredictions = { predictions: [ { description: '123 Main St, New York, NY, USA', place_id: 'ChIJ123abc', types: ['street_address'] }, { description: '456 Oak Ave, New York, NY, USA', place_id: 'ChIJ456def', types: ['street_address'] } ] }; it('should return autocomplete predictions successfully', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: '123 Main', types: ['address'], componentRestrictions: { country: 'us' }, sessionToken: 'session123' }); expect(response.status).toBe(200); expect(response.body).toEqual(mockPredictions); expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith( '123 Main', { types: ['address'], componentRestrictions: { country: 'us' }, sessionToken: 'session123' } ); expect(consoleLogSpy).toHaveBeenCalledWith( 'Places Autocomplete: user=1, query_length=8, results=2' ); }); it('should use default types when not provided', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(200); expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith( 'test', { types: ['address'], componentRestrictions: undefined, sessionToken: undefined } ); }); it('should return empty predictions for short input', async () => { const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'a' }); expect(response.status).toBe(200); expect(response.body).toEqual({ predictions: [] }); expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled(); }); it('should return empty predictions for missing input', async () => { const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({}); expect(response.status).toBe(200); expect(response.body).toEqual({ predictions: [] }); expect(googleMapsService.getPlacesAutocomplete).not.toHaveBeenCalled(); }); it('should require authentication', async () => { const response = await request(app) .post('/maps/places/autocomplete') .send({ input: 'test' }); expect(response.status).toBe(401); expect(response.body).toEqual({ error: 'No token provided' }); }); it('should log request with user ID from authenticated user', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue(mockPredictions); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(200); // Should log with user ID expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('user=1') ); }); it('should handle empty predictions from service', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'nonexistent place' }); expect(response.status).toBe(200); expect(response.body).toEqual({ predictions: [] }); expect(consoleLogSpy).toHaveBeenCalledWith( 'Places Autocomplete: user=1, query_length=17, results=0' ); }); it('should handle service response without predictions array', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({}); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(200); expect(response.body).toEqual({}); expect(consoleLogSpy).toHaveBeenCalledWith( 'Places Autocomplete: user=1, query_length=4, results=0' ); }); }); describe('POST /places/details', () => { const mockPlaceDetails = { result: { place_id: 'ChIJ123abc', name: 'Central Park', formatted_address: 'New York, NY 10024, USA', geometry: { location: { lat: 40.785091, lng: -73.968285 } }, types: ['park', 'point_of_interest'] } }; it('should return place details successfully', async () => { googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails); const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'ChIJ123abc', sessionToken: 'session123' }); expect(response.status).toBe(200); expect(response.body).toEqual(mockPlaceDetails); expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith( 'ChIJ123abc', { sessionToken: 'session123' } ); expect(consoleLogSpy).toHaveBeenCalledWith( 'Place Details: user=1, placeId=ChIJ123abc...' ); }); it('should handle place details without session token', async () => { googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails); const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'ChIJ123abc' }); expect(response.status).toBe(200); expect(googleMapsService.getPlaceDetails).toHaveBeenCalledWith( 'ChIJ123abc', { sessionToken: undefined } ); }); it('should return error for missing place ID', async () => { const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({}); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Place ID is required' }); expect(googleMapsService.getPlaceDetails).not.toHaveBeenCalled(); }); it('should return error for empty place ID', async () => { const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: '' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Place ID is required' }); }); it('should require authentication', async () => { const response = await request(app) .post('/maps/places/details') .send({ placeId: 'ChIJ123abc' }); expect(response.status).toBe(401); }); it('should handle very long place IDs in logging', async () => { const longPlaceId = 'ChIJ' + 'a'.repeat(100); googleMapsService.getPlaceDetails.mockResolvedValue(mockPlaceDetails); const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: longPlaceId }); expect(response.status).toBe(200); expect(consoleLogSpy).toHaveBeenCalledWith( `Place Details: user=1, placeId=${longPlaceId.substring(0, 10)}...` ); }); it('should handle service errors', async () => { const serviceError = new Error('Place not found'); googleMapsService.getPlaceDetails.mockRejectedValue(serviceError); const response = await request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'ChIJ123abc' }); expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Failed to process request', details: 'Place not found' }); }); }); describe('POST /geocode', () => { const mockGeocodeResults = { results: [ { formatted_address: '123 Main St, New York, NY 10001, USA', geometry: { location: { lat: 40.7484405, lng: -73.9856644 } }, place_id: 'ChIJ123abc', types: ['street_address'] } ] }; it('should return geocoding results successfully', async () => { googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: '123 Main St, New York, NY', componentRestrictions: { country: 'US' } }); expect(response.status).toBe(200); expect(response.body).toEqual(mockGeocodeResults); expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith( '123 Main St, New York, NY', { componentRestrictions: { country: 'US' } } ); expect(consoleLogSpy).toHaveBeenCalledWith( 'Geocoding: user=1, address_length=25' ); }); it('should handle geocoding without component restrictions', async () => { googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: '123 Main St' }); expect(response.status).toBe(200); expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith( '123 Main St', { componentRestrictions: undefined } ); }); it('should return error for missing address', async () => { const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({}); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Address is required' }); expect(googleMapsService.geocodeAddress).not.toHaveBeenCalled(); }); it('should return error for empty address', async () => { const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: '' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Address is required' }); }); it('should require authentication', async () => { const response = await request(app) .post('/maps/geocode') .send({ address: '123 Main St' }); expect(response.status).toBe(401); }); it('should handle addresses with special characters', async () => { googleMapsService.geocodeAddress.mockResolvedValue(mockGeocodeResults); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: '123 Main St, Apt #4B' }); expect(response.status).toBe(200); expect(googleMapsService.geocodeAddress).toHaveBeenCalledWith( '123 Main St, Apt #4B', { componentRestrictions: undefined } ); }); it('should handle service errors', async () => { const serviceError = new Error('Invalid address'); googleMapsService.geocodeAddress.mockRejectedValue(serviceError); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: 'invalid address' }); expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Failed to process request', details: 'Invalid address' }); }); it('should handle empty geocoding results', async () => { googleMapsService.geocodeAddress.mockResolvedValue({ results: [] }); const response = await request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: 'nonexistent address' }); expect(response.status).toBe(200); expect(response.body).toEqual({ results: [] }); }); }); describe('GET /health', () => { it('should return healthy status when service is configured', async () => { googleMapsService.isConfigured.mockReturnValue(true); const response = await request(app) .get('/maps/health'); expect(response.status).toBe(200); expect(response.body).toEqual({ status: 'healthy', service: 'Google Maps API Proxy', timestamp: expect.any(String), configuration: { apiKeyConfigured: true } }); // Verify timestamp is a valid ISO string expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp); }); it('should return unavailable status when service is not configured', async () => { googleMapsService.isConfigured.mockReturnValue(false); const response = await request(app) .get('/maps/health'); expect(response.status).toBe(503); expect(response.body).toEqual({ status: 'unavailable', service: 'Google Maps API Proxy', timestamp: expect.any(String), configuration: { apiKeyConfigured: false } }); }); it('should not require authentication', async () => { googleMapsService.isConfigured.mockReturnValue(true); const response = await request(app) .get('/maps/health'); expect(response.status).toBe(200); // Should work without authorization header }); it('should always return current timestamp', async () => { googleMapsService.isConfigured.mockReturnValue(true); const beforeTime = new Date().toISOString(); const response = await request(app) .get('/maps/health'); const afterTime = new Date().toISOString(); expect(response.status).toBe(200); expect(new Date(response.body.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(beforeTime).getTime()); expect(new Date(response.body.timestamp).getTime()).toBeLessThanOrEqual(new Date(afterTime).getTime()); }); }); describe('Rate Limiting Integration', () => { it('should apply burst protection to all endpoints', async () => { // This test verifies that rate limiting middleware is applied // In a real scenario, we'd test actual rate limiting behavior googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test' }); expect(response.status).toBe(200); // The fact that the request succeeded means rate limiting middleware was applied without blocking }); }); describe('Edge Cases and Security', () => { it('should handle null input gracefully', async () => { const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: null }); expect(response.status).toBe(200); expect(response.body).toEqual({ predictions: [] }); }); it('should handle undefined values in request body', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test', types: undefined, componentRestrictions: undefined, sessionToken: undefined }); expect(response.status).toBe(200); expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith( 'test', { types: ['address'], // Should use default componentRestrictions: undefined, sessionToken: undefined } ); }); it('should handle malformed JSON gracefully', async () => { const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .set('Content-Type', 'application/json') .send('invalid json'); expect(response.status).toBe(400); // Express will handle malformed JSON }); it('should sanitize input to prevent injection attacks', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); const maliciousInput = ''; const response = await request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: maliciousInput }); expect(response.status).toBe(200); // Input should be treated as string and passed through expect(googleMapsService.getPlacesAutocomplete).toHaveBeenCalledWith( maliciousInput, expect.any(Object) ); }); it('should handle concurrent requests to different endpoints', async () => { googleMapsService.getPlacesAutocomplete.mockResolvedValue({ predictions: [] }); googleMapsService.getPlaceDetails.mockResolvedValue({ result: {} }); googleMapsService.geocodeAddress.mockResolvedValue({ results: [] }); const [response1, response2, response3] = await Promise.all([ request(app) .post('/maps/places/autocomplete') .set('Authorization', 'Bearer valid_token') .send({ input: 'test1' }), request(app) .post('/maps/places/details') .set('Authorization', 'Bearer valid_token') .send({ placeId: 'ChIJ123abc' }), request(app) .post('/maps/geocode') .set('Authorization', 'Bearer valid_token') .send({ address: 'test address' }) ]); expect(response1.status).toBe(200); expect(response2.status).toBe(200); expect(response3.status).toBe(200); }); }); });