All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 25m33s
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
// 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<typeof import('./googleGeocodingService.server')>();
|
|
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();
|
|
});
|
|
});
|
|
});
|