// src/services/geocodingService.server.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GeocodingService } from './geocodingService.server'; import { GoogleGeocodingService } from './googleGeocodingService.server'; import { NominatimGeocodingService } from './nominatimGeocodingService.server'; // --- Hoisted Mocks --- const mocks = vi.hoisted(() => ({ mockRedis: { get: vi.fn(), set: vi.fn(), scan: vi.fn(), del: vi.fn(), }, })); // --- Mock Modules --- vi.mock('./queueService.server', () => ({ connection: mocks.mockRedis, })); vi.mock('./googleGeocodingService.server', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, GoogleGeocodingService: vi.fn() }; }); vi.mock('./logger.server', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); import { logger } from './logger.server'; describe('Geocoding Service', () => { let geocodingService: GeocodingService; let mockGoogleService: GoogleGeocodingService; let mockNominatimService: NominatimGeocodingService; beforeEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); // Create a mock instance of the Google service mockGoogleService = { geocode: vi.fn() } as unknown as GoogleGeocodingService; // Create a mock instance of the dependency and the service under test mockNominatimService = { geocode: vi.fn() } as unknown as NominatimGeocodingService; geocodingService = new GeocodingService(mockGoogleService, mockNominatimService); }); afterEach(() => { vi.unstubAllEnvs(); }); describe('geocodeAddress', () => { const address = '123 Main St, Anytown'; const cacheKey = `geocode:${address}`; const coordinates = { lat: 45.0, lng: -75.0 }; it('should return coordinates from Redis cache if available', async () => { // Arrange: Mock Redis to return a cached result mocks.mockRedis.get.mockResolvedValue(JSON.stringify(coordinates)); const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey); expect(mockGoogleService.geocode).not.toHaveBeenCalled(); expect(mockNominatimService.geocode).not.toHaveBeenCalled(); }); it('should log an error but continue if Redis GET fails', async () => { // Arrange: Mock Redis 'get' to fail, but Google API to succeed vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockRejectedValue(new Error('Redis down')); vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(logger.error).toHaveBeenCalledWith( { err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.', ); expect(mockGoogleService.geocode).toHaveBeenCalled(); // Should still proceed to fetch }); it('should proceed to fetch if cached data is invalid JSON', async () => { // Arrange: Mock Redis to return a malformed JSON string vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue('{ "lat": 45.0, "lng": -75.0 '); // Missing closing brace vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(mocks.mockRedis.get).toHaveBeenCalledWith(cacheKey); // The service should log the JSON parsing error and continue expect(logger.error).toHaveBeenCalledWith( { err: expect.any(SyntaxError), cacheKey: expect.any(String) }, 'Redis GET or JSON.parse command failed. Proceeding without cache.', ); expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1); }); it('should fetch from Google, return coordinates, and cache the result on cache miss', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue(null); // Cache miss vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(mockGoogleService.geocode).toHaveBeenCalledWith(address, logger); expect(mocks.mockRedis.set).toHaveBeenCalledWith( cacheKey, JSON.stringify(coordinates), 'EX', expect.any(Number), ); expect(mockNominatimService.geocode).not.toHaveBeenCalled(); }); it('should fall back to Nominatim if Google API key is missing', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', ''); mocks.mockRedis.get.mockResolvedValue(null); vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('GOOGLE_MAPS_API_KEY is not set'), ); expect(mockGoogleService.geocode).not.toHaveBeenCalled(); expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger); expect(mocks.mockRedis.set).toHaveBeenCalled(); }); it('should fall back to Nominatim if Google API returns a non-OK status', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue(null); vi.mocked(mockGoogleService.geocode).mockResolvedValue(null); // Google returns no results vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(logger.info).toHaveBeenCalledWith( { address: expect.any(String), provider: 'Google' }, expect.stringContaining('Falling back to Nominatim'), ); expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger); }); it('should fall back to Nominatim if Google API fetch call fails', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue(null); vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error')); vi.mocked(mockNominatimService.geocode).mockResolvedValue(coordinates); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); expect(logger.error).toHaveBeenCalledWith( { err: expect.any(Error) }, expect.stringContaining('An error occurred while calling the Google Maps Geocoding API'), ); expect(mockNominatimService.geocode).toHaveBeenCalledWith(address, logger); }); it('should return null and log an error if both Google and Nominatim fail', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue(null); vi.mocked(mockGoogleService.geocode).mockRejectedValue(new Error('Network Error')); vi.mocked(mockNominatimService.geocode).mockResolvedValue(null); // Nominatim also fails // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toBeNull(); expect(logger.error).toHaveBeenCalledWith({ address }, 'All geocoding providers failed.'); expect(mocks.mockRedis.set).not.toHaveBeenCalled(); // Should not cache a null result }); it('should return coordinates even if Redis SET fails', async () => { // Arrange vi.stubEnv('GOOGLE_MAPS_API_KEY', 'test-key'); mocks.mockRedis.get.mockResolvedValue(null); // Cache miss vi.mocked(mockGoogleService.geocode).mockResolvedValue(coordinates); // Mock Redis 'set' to fail mocks.mockRedis.set.mockRejectedValue(new Error('Redis SET failed')); // Act const result = await geocodingService.geocodeAddress(address, logger); // Assert expect(result).toEqual(coordinates); // The result should still be returned to the caller expect(mockGoogleService.geocode).toHaveBeenCalledTimes(1); expect(mocks.mockRedis.set).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith( { err: expect.any(Error), cacheKey: expect.any(String) }, 'Redis SET command failed. Result will not be cached.', ); }); }); describe('clearGeocodeCache', () => { it('should scan and delete keys in batches', async () => { // Arrange: Mock SCAN to run twice mocks.mockRedis.scan .mockResolvedValueOnce(['10', ['geocode:key1', 'geocode:key2']]) // First batch .mockResolvedValueOnce(['0', ['geocode:key3']]); // Second batch, cursor is '0' to end loop // Mock DEL to return the number of keys it "deleted" mocks.mockRedis.del.mockResolvedValueOnce(2).mockResolvedValueOnce(1); // Act const result = await geocodingService.clearGeocodeCache(logger); // Assert expect(result).toBe(3); // 2 + 1 expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(2); expect(mocks.mockRedis.del).toHaveBeenCalledTimes(2); expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key1', 'geocode:key2']); expect(mocks.mockRedis.del).toHaveBeenCalledWith(['geocode:key3']); }); it('should return 0 if no keys match the pattern', async () => { // Arrange: Mock SCAN to find no keys mocks.mockRedis.scan.mockResolvedValueOnce(['0', []]); // Act const result = await geocodingService.clearGeocodeCache(logger); // Assert expect(result).toBe(0); expect(mocks.mockRedis.scan).toHaveBeenCalledTimes(1); expect(mocks.mockRedis.del).not.toHaveBeenCalled(); }); it('should throw an error if Redis SCAN fails', async () => { // Arrange: Mock SCAN to reject with an error const redisError = new Error('Redis is down'); mocks.mockRedis.scan.mockRejectedValue(redisError); // Act & Assert await expect(geocodingService.clearGeocodeCache(logger)).rejects.toThrow(redisError); expect(logger.error).toHaveBeenCalledWith( { err: expect.any(Error) }, 'Failed to clear geocode cache from Redis.', ); expect(mocks.mockRedis.del).not.toHaveBeenCalled(); }); }); });