Gas Station Feature
This commit is contained in:
95
backend/src/features/stations/tests/fixtures/mock-google-response.ts
vendored
Normal file
95
backend/src/features/stations/tests/fixtures/mock-google-response.ts
vendored
Normal 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'
|
||||
};
|
||||
79
backend/src/features/stations/tests/fixtures/mock-stations.ts
vendored
Normal file
79
backend/src/features/stations/tests/fixtures/mock-stations.ts
vendored
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user