Files
rentall-app/backend/tests/unit/routes/maps.test.js
2025-09-19 19:46:41 -04:00

726 lines
23 KiB
JavaScript

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 = '<script>alert("xss")</script>';
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);
});
});
});