Gas Station Feature

This commit is contained in:
Eric Gullickson
2025-11-04 18:46:46 -06:00
parent d8d0ada83f
commit 5dc58d73b9
61 changed files with 12952 additions and 52 deletions

View File

@@ -0,0 +1,95 @@
/**
* @ai-summary Mock Google Places API responses for tests
*/
import { GooglePlacesResponse } from '../../external/google-maps/google-maps.types';
export const mockGoogleNearbySearchResponse: GooglePlacesResponse = {
results: [
{
geometry: {
location: {
lat: 37.7749,
lng: -122.4194
}
},
name: 'Shell Gas Station - Downtown',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
vicinity: '123 Main St, San Francisco, CA 94105',
rating: 4.2,
photos: [
{
photo_reference: 'photo_ref_1'
}
],
opening_hours: {
open_now: true
},
types: ['gas_station', 'point_of_interest', 'establishment']
},
{
geometry: {
location: {
lat: 37.7923,
lng: -122.3989
}
},
name: 'Chevron Station - Financial District',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLde',
vicinity: '456 Market St, San Francisco, CA 94102',
rating: 4.5,
photos: [
{
photo_reference: 'photo_ref_2'
}
],
opening_hours: {
open_now: true
},
types: ['gas_station', 'point_of_interest', 'establishment']
}
],
status: 'OK'
};
export const mockGooglePlaceDetailsResponse = {
result: {
geometry: {
location: {
lat: 37.7749,
lng: -122.4194
}
},
name: 'Shell Gas Station - Downtown',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
formatted_address: '123 Main St, San Francisco, CA 94105',
rating: 4.2,
user_ratings_total: 150,
formatted_phone_number: '+1 (415) 555-0100',
website: 'https://www.shell.com',
opening_hours: {
weekday_text: [
'Monday: 12:00 AM 11:59 PM',
'Tuesday: 12:00 AM 11:59 PM'
]
},
photos: [
{
photo_reference: 'photo_ref_1'
}
],
types: ['gas_station', 'point_of_interest', 'establishment']
},
status: 'OK'
};
export const mockGoogleErrorResponse = {
results: [],
status: 'ZERO_RESULTS'
};
export const mockGoogleApiErrorResponse = {
results: [],
status: 'REQUEST_DENIED',
error_message: 'Invalid API key'
};

View File

@@ -0,0 +1,79 @@
/**
* @ai-summary Mock station data for tests
*/
import { Station, SavedStation } from '../../domain/stations.types';
export const mockStations: Station[] = [
{
id: 'station-1',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
name: 'Shell Gas Station - Downtown',
address: '123 Main St, San Francisco, CA 94105',
latitude: 37.7749,
longitude: -122.4194,
rating: 4.2,
distance: 250,
photoUrl: 'https://example.com/shell-downtown.jpg',
priceRegular: 4.29,
pricePremium: 4.79,
priceDiesel: 4.49
},
{
id: 'station-2',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLde',
name: 'Chevron Station - Financial District',
address: '456 Market St, San Francisco, CA 94102',
latitude: 37.7923,
longitude: -122.3989,
rating: 4.5,
distance: 1200,
photoUrl: 'https://example.com/chevron-fd.jpg',
priceRegular: 4.39,
pricePremium: 4.89
},
{
id: 'station-3',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLdf',
name: 'Exxon Mobile - Mission',
address: '789 Valencia St, San Francisco, CA 94103',
latitude: 37.7599,
longitude: -122.4148,
rating: 3.8,
distance: 1850,
photoUrl: 'https://example.com/exxon-mission.jpg',
priceRegular: 4.19,
priceDiesel: 4.39
}
];
export const mockSavedStations: SavedStation[] = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
userId: 'user123',
stationId: mockStations[0].placeId,
nickname: 'Work Gas Station',
notes: 'Usually has good prices, rewards program available',
isFavorite: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15')
},
{
id: '550e8400-e29b-41d4-a716-446655440001',
userId: 'user123',
stationId: mockStations[1].placeId,
nickname: 'Home Station',
notes: 'Closest to apartment',
isFavorite: true,
createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-10')
}
];
export const searchCoordinates = {
sanFrancisco: { latitude: 37.7749, longitude: -122.4194 },
losAngeles: { latitude: 34.0522, longitude: -118.2437 },
seattle: { latitude: 47.6062, longitude: -122.3321 }
};
export const mockUserId = 'user123';

View File

@@ -0,0 +1,386 @@
/**
* @ai-summary Integration tests for Stations API endpoints
*/
import { FastifyInstance } from 'fastify';
import { buildApp } from '../../../../app';
import { pool } from '../../../../core/config/database';
import {
mockStations,
mockUserId,
searchCoordinates
} from '../fixtures/mock-stations';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
jest.mock('../../external/google-maps/google-maps.client');
describe('Stations API Integration Tests', () => {
let app: FastifyInstance;
const mockToken = 'test-jwt-token';
const authHeader = { authorization: `Bearer ${mockToken}` };
beforeAll(async () => {
app = await buildApp();
await app.ready();
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
jest.clearAllMocks();
// Clean up test data
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', [mockUserId]);
await pool.query('DELETE FROM station_cache');
});
describe('POST /api/stations/search', () => {
it('should search for nearby stations', async () => {
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(mockStations);
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: authHeader,
payload: {
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude,
radius: 5000
}
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.stations).toBeDefined();
expect(body.searchLocation).toBeDefined();
expect(body.searchRadius).toBe(5000);
});
it('should return 400 for missing coordinates', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: authHeader,
payload: {
radius: 5000
}
});
expect(response.statusCode).toBe(400);
const body = JSON.parse(response.body);
expect(body.message).toContain('required');
});
it('should return 401 without authentication', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
payload: {
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
}
});
expect(response.statusCode).toBe(401);
});
it('should validate coordinate ranges', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: authHeader,
payload: {
latitude: 91, // Invalid: max is 90
longitude: searchCoordinates.sanFrancisco.longitude
}
});
expect(response.statusCode).toBe(400);
});
});
describe('POST /api/stations/save', () => {
beforeEach(async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
// Cache a station first
await pool.query(
`INSERT INTO station_cache (place_id, name, address, latitude, longitude, rating)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
station.placeId,
station.name,
station.address,
station.latitude,
station.longitude,
station.rating
]
);
});
it('should save a station to user favorites', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const response = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: authHeader,
payload: {
placeId: station.placeId,
nickname: 'Work Gas Station'
}
});
expect(response.statusCode).toBe(201);
const body = JSON.parse(response.body);
expect(body.station).toBeDefined();
});
it('should require valid placeId', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: authHeader,
payload: {
placeId: ''
}
});
expect(response.statusCode).toBe(400);
});
it('should handle station not in cache', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: authHeader,
payload: {
placeId: 'non-existent-place-id'
}
});
expect(response.statusCode).toBe(404);
});
it('should verify user isolation', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
// Save for one user
await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: authHeader,
payload: {
placeId: station.placeId
}
});
// Verify another user can't see it
const otherUserHeaders = { authorization: 'Bearer other-user-token' };
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: otherUserHeaders
});
const body = JSON.parse(response.body);
expect(body).toEqual([]);
});
});
describe('GET /api/stations/saved', () => {
beforeEach(async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
// Insert test data
await pool.query(
`INSERT INTO station_cache (place_id, name, address, latitude, longitude)
VALUES ($1, $2, $3, $4, $5)`,
[
station.placeId,
station.name,
station.address,
station.latitude,
station.longitude
]
);
await pool.query(
`INSERT INTO saved_stations (user_id, place_id, nickname, notes, is_favorite)
VALUES ($1, $2, $3, $4, $5)`,
[
mockUserId,
station.placeId,
'Test Station',
'Test notes',
true
]
);
});
it('should return user saved stations', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: authHeader
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(0);
});
it('should only return current user stations', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: authHeader
});
const body = JSON.parse(response.body);
body.forEach((station: any) => {
expect(station.userId).toBe(mockUserId);
});
});
it('should return empty array for user with no saved stations', async () => {
const otherUserHeaders = { authorization: 'Bearer other-user-token' };
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: otherUserHeaders
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body).toEqual([]);
});
it('should include station metadata', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: authHeader
});
const body = JSON.parse(response.body);
const station = body[0];
expect(station).toHaveProperty('id');
expect(station).toHaveProperty('nickname');
expect(station).toHaveProperty('notes');
expect(station).toHaveProperty('isFavorite');
});
});
describe('DELETE /api/stations/saved/:placeId', () => {
beforeEach(async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
await pool.query(
`INSERT INTO station_cache (place_id, name, address, latitude, longitude)
VALUES ($1, $2, $3, $4, $5)`,
[
station.placeId,
station.name,
station.address,
station.latitude,
station.longitude
]
);
await pool.query(
`INSERT INTO saved_stations (user_id, place_id, nickname)
VALUES ($1, $2, $3)`,
[mockUserId, station.placeId, 'Test Station']
);
});
it('should delete a saved station', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const response = await app.inject({
method: 'DELETE',
url: `/api/stations/saved/${station.placeId}`,
headers: authHeader
});
expect(response.statusCode).toBe(204);
});
it('should return 404 if station not found', async () => {
const response = await app.inject({
method: 'DELETE',
url: '/api/stations/saved/non-existent-id',
headers: authHeader
});
expect(response.statusCode).toBe(404);
});
it('should verify ownership before deleting', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const otherUserHeaders = { authorization: 'Bearer other-user-token' };
const response = await app.inject({
method: 'DELETE',
url: `/api/stations/saved/${station.placeId}`,
headers: otherUserHeaders
});
expect(response.statusCode).toBe(404);
});
});
describe('Error Handling', () => {
it('should handle Google Maps API errors gracefully', async () => {
(googleMapsClient.searchNearbyStations as jest.Mock).mockRejectedValue(
new Error('API Error')
);
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: authHeader,
payload: {
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
}
});
expect(response.statusCode).toBe(500);
const body = JSON.parse(response.body);
expect(body.error).toBeDefined();
});
it('should validate request schema', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: authHeader,
payload: {
invalidField: 'test'
}
});
expect(response.statusCode).toBe(400);
});
it('should require authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/stations/saved'
});
expect(response.statusCode).toBe(401);
});
});
});

View File

@@ -0,0 +1,168 @@
/**
* @ai-summary Unit tests for Google Maps client
*/
import axios from 'axios';
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
import {
mockGoogleNearbySearchResponse,
mockGoogleErrorResponse,
mockGoogleApiErrorResponse
} from '../fixtures/mock-google-response';
import { searchCoordinates } from '../fixtures/mock-stations';
jest.mock('axios');
jest.mock('../../../../core/config/redis');
jest.mock('../../../../core/logging/logger');
describe('GoogleMapsClient', () => {
let client: GoogleMapsClient;
let mockAxios: jest.Mocked<typeof axios>;
beforeEach(() => {
jest.clearAllMocks();
mockAxios = axios as jest.Mocked<typeof axios>;
client = new GoogleMapsClient();
});
describe('searchNearbyStations', () => {
it('should search for nearby gas stations', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude,
5000
);
expect(result).toHaveLength(2);
expect(result[0]?.name).toBe('Shell Gas Station - Downtown');
expect(mockAxios.get).toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleApiErrorResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result).toEqual([]);
});
it('should handle zero results', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleErrorResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.seattle.latitude,
searchCoordinates.seattle.longitude
);
expect(result).toEqual([]);
});
it('should handle network errors', async () => {
mockAxios.get.mockRejectedValue(new Error('Network error'));
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result).toEqual([]);
});
it('should calculate distance from reference point', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result[0]?.distance).toBeGreaterThan(0);
});
it('should format API response correctly', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result[0]).toHaveProperty('placeId');
expect(result[0]).toHaveProperty('name');
expect(result[0]).toHaveProperty('address');
expect(result[0]).toHaveProperty('latitude');
expect(result[0]).toHaveProperty('longitude');
expect(result[0]).toHaveProperty('rating');
});
it('should respect custom radius parameter', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude,
2000
);
const callArgs = mockAxios.get.mock.calls[0]?.[1];
expect(callArgs?.params?.radius).toBe(2000);
});
});
describe('caching', () => {
it('should cache search results', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
// First call should hit API
const result1 = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
// Second call should return cached result
const result2 = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result1).toEqual(result2);
});
it('should use different cache keys for different coordinates', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
await client.searchNearbyStations(
searchCoordinates.losAngeles.latitude,
searchCoordinates.losAngeles.longitude
);
expect(mockAxios.get).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* @ai-summary Unit tests for StationsService
*/
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import {
mockStations,
mockSavedStations,
mockUserId,
searchCoordinates
} from '../fixtures/mock-stations';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('StationsService', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
beforeEach(() => {
jest.clearAllMocks();
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn(),
deleteSavedStation: jest.fn()
} as unknown as jest.Mocked<StationsRepository>;
(StationsRepository as jest.Mock).mockImplementation(() => mockRepository);
(googleMapsClient.searchNearbyStations as jest.Mock) = jest.fn().mockResolvedValue(mockStations);
service = new StationsService(mockRepository);
});
describe('searchNearbyStations', () => {
it('should search for nearby stations and cache results', async () => {
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude,
radius: 5000
},
mockUserId
);
expect(result.stations).toHaveLength(3);
expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown');
expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3);
});
it('should sort stations by distance', async () => {
const stationsWithDistance = [
{ ...mockStations[0], distance: 500 },
{ ...mockStations[1], distance: 100 },
{ ...mockStations[2], distance: 2000 }
];
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(
stationsWithDistance
);
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
},
mockUserId
);
expect(result.stations[0]?.distance).toBe(100);
expect(result.stations[1]?.distance).toBe(500);
expect(result.stations[2]?.distance).toBe(2000);
});
it('should return search metadata', async () => {
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude,
radius: 3000
},
mockUserId
);
expect(result.searchLocation.latitude).toBe(
searchCoordinates.sanFrancisco.latitude
);
expect(result.searchLocation.longitude).toBe(
searchCoordinates.sanFrancisco.longitude
);
expect(result.searchRadius).toBe(3000);
expect(result.timestamp).toBeDefined();
});
it('should use default radius if not provided', async () => {
await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
},
mockUserId
);
expect(mockRepository.cacheStation).toHaveBeenCalled();
});
});
describe('saveStation', () => {
it('should save a station from cache', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const stationId = station.placeId;
mockRepository.getCachedStation.mockResolvedValue(station);
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.saveStation.mockResolvedValue(savedStation);
const result = await service.saveStation(stationId, mockUserId, {
nickname: 'Work Gas Station'
});
expect(mockRepository.getCachedStation).toHaveBeenCalledWith(stationId);
expect(mockRepository.saveStation).toHaveBeenCalledWith(
mockUserId,
stationId,
{ nickname: 'Work Gas Station' }
);
expect(result).toHaveProperty('id');
});
it('should throw error if station not in cache', async () => {
mockRepository.getCachedStation.mockResolvedValue(null);
await expect(
service.saveStation('unknown-id', mockUserId)
).rejects.toThrow('Station not found');
});
it('should save station with custom metadata', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.getCachedStation.mockResolvedValue(station);
mockRepository.saveStation.mockResolvedValue(savedStation);
await service.saveStation(station.placeId, mockUserId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
});
expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
});
});
});
describe('getUserSavedStations', () => {
it('should return all saved stations for user', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
mockRepository.getUserSavedStations.mockResolvedValue(mockSavedStations);
mockRepository.getCachedStation.mockResolvedValue(station);
const result = await service.getUserSavedStations(mockUserId);
expect(mockRepository.getUserSavedStations).toHaveBeenCalledWith(mockUserId);
expect(result).toBeDefined();
expect(result.length).toBe(mockSavedStations.length);
});
it('should return empty array if user has no saved stations', async () => {
mockRepository.getUserSavedStations.mockResolvedValue([]);
const result = await service.getUserSavedStations('other-user');
expect(result).toEqual([]);
});
});
describe('removeSavedStation', () => {
it('should delete a saved station', async () => {
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.deleteSavedStation.mockResolvedValue(true);
await service.removeSavedStation(
savedStation.stationId,
mockUserId
);
expect(mockRepository.deleteSavedStation).toHaveBeenCalledWith(
mockUserId,
savedStation.stationId
);
});
it('should throw error if station not found', async () => {
mockRepository.deleteSavedStation.mockResolvedValue(false);
await expect(
service.removeSavedStation('non-existent', mockUserId)
).rejects.toThrow('Saved station not found');
});
it('should verify user isolation', async () => {
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.deleteSavedStation.mockResolvedValue(true);
await service.removeSavedStation(savedStation.stationId, 'other-user');
expect(mockRepository.deleteSavedStation).toHaveBeenCalledWith(
'other-user',
savedStation.stationId
);
});
});
});