Gas Station Feature
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @ai-summary Tests for stations API client
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { stationsApi } from '../../api/stations.api';
|
||||
import { Station, StationSearchRequest } from '../../types/stations.types';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{
|
||||
placeId: 'test-1',
|
||||
name: 'Shell Station',
|
||||
address: '123 Main St',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250
|
||||
}
|
||||
];
|
||||
|
||||
describe('stationsApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('searchStations', () => {
|
||||
it('should search for stations with valid request', async () => {
|
||||
const request: StationSearchRequest = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { stations: mockStations }
|
||||
});
|
||||
|
||||
const result = await stationsApi.searchStations(request);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/stations/search',
|
||||
request
|
||||
);
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should handle search without radius', async () => {
|
||||
const request: StationSearchRequest = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { stations: mockStations }
|
||||
});
|
||||
|
||||
await stationsApi.searchStations(request);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/stations/search',
|
||||
request
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
mockedAxios.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('should handle 401 unauthorized', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 401, data: { message: 'Unauthorized' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle 500 server error', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Internal server error' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveStation', () => {
|
||||
it('should save a station with metadata', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
const data = {
|
||||
nickname: 'Work Station',
|
||||
notes: 'Best prices',
|
||||
isFavorite: true
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { id: '123', ...data, placeId }
|
||||
});
|
||||
|
||||
const result = await stationsApi.saveStation(placeId, data);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
|
||||
placeId,
|
||||
...data
|
||||
});
|
||||
expect(result.placeId).toBe(placeId);
|
||||
});
|
||||
|
||||
it('should save station without optional fields', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { id: '123', placeId }
|
||||
});
|
||||
|
||||
await stationsApi.saveStation(placeId);
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith('/api/stations/save', {
|
||||
placeId
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle save errors', async () => {
|
||||
mockedAxios.post.mockRejectedValue({
|
||||
response: { status: 404, data: { message: 'Station not found' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.saveStation('invalid-id')
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSavedStations', () => {
|
||||
it('should fetch all saved stations', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: mockStations
|
||||
});
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledWith('/api/stations/saved');
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should return empty array when no saved stations', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: []
|
||||
});
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle fetch errors', async () => {
|
||||
mockedAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(stationsApi.getSavedStations()).rejects.toThrow(
|
||||
'Network error'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSavedStation', () => {
|
||||
it('should delete a station by placeId', async () => {
|
||||
const placeId = 'test-place-id';
|
||||
|
||||
mockedAxios.delete.mockResolvedValue({ status: 204 });
|
||||
|
||||
await stationsApi.deleteSavedStation(placeId);
|
||||
|
||||
expect(mockedAxios.delete).toHaveBeenCalledWith(
|
||||
`/api/stations/saved/${placeId}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 404 not found', async () => {
|
||||
mockedAxios.delete.mockRejectedValue({
|
||||
response: { status: 404, data: { message: 'Not found' } }
|
||||
});
|
||||
|
||||
await expect(
|
||||
stationsApi.deleteSavedStation('invalid-id')
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockedAxios.delete.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
stationsApi.deleteSavedStation('test-id')
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Construction', () => {
|
||||
it('should use correct API base path', async () => {
|
||||
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
|
||||
|
||||
await stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
const callUrl = mockedAxios.post.mock.calls[0][0];
|
||||
expect(callUrl).toContain('/api/stations');
|
||||
});
|
||||
|
||||
it('should construct correct saved station URL', async () => {
|
||||
mockedAxios.delete.mockResolvedValue({ status: 204 });
|
||||
|
||||
await stationsApi.deleteSavedStation('test-place-id');
|
||||
|
||||
const callUrl = mockedAxios.delete.mock.calls[0][0];
|
||||
expect(callUrl).toBe('/api/stations/saved/test-place-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Payload Validation', () => {
|
||||
it('should send correct payload for search', async () => {
|
||||
const request = {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: { stations: [] } });
|
||||
|
||||
await stationsApi.searchStations(request);
|
||||
|
||||
const payload = mockedAxios.post.mock.calls[0][1];
|
||||
expect(payload).toEqual(request);
|
||||
});
|
||||
|
||||
it('should send correct payload for save', async () => {
|
||||
const placeId = 'test-id';
|
||||
const data = { nickname: 'Test', isFavorite: true };
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: {} });
|
||||
|
||||
await stationsApi.saveStation(placeId, data);
|
||||
|
||||
const payload = mockedAxios.post.mock.calls[0][1];
|
||||
expect(payload).toEqual({ placeId, ...data });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Parsing', () => {
|
||||
it('should parse search response correctly', async () => {
|
||||
const responseData = {
|
||||
stations: mockStations,
|
||||
searchLocation: { latitude: 37.7749, longitude: -122.4194 },
|
||||
searchRadius: 5000
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({ data: responseData });
|
||||
|
||||
const result = await stationsApi.searchStations({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
|
||||
it('should parse saved stations response', async () => {
|
||||
mockedAxios.get.mockResolvedValue({ data: mockStations });
|
||||
|
||||
const result = await stationsApi.getSavedStations();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toEqual(mockStations);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @ai-summary Tests for StationCard component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { StationCard } from '../../components/StationCard';
|
||||
import { Station } from '../../types/stations.types';
|
||||
|
||||
const mockStation: Station = {
|
||||
placeId: 'test-place-id',
|
||||
name: 'Shell Gas Station',
|
||||
address: '123 Main St, San Francisco, CA 94105',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250,
|
||||
photoUrl: 'https://example.com/photo.jpg'
|
||||
};
|
||||
|
||||
describe('StationCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.open = jest.fn();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render station name and address', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
expect(screen.getByText('123 Main St, San Francisco, CA 94105')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render station photo if available', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const photo = screen.getByAltText('Shell Gas Station');
|
||||
expect(photo).toBeInTheDocument();
|
||||
expect(photo).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
it('should render rating when available', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('4.2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render distance chip', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText(/mi/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not crash when photo is missing', () => {
|
||||
const stationWithoutPhoto = { ...mockStation, photoUrl: undefined };
|
||||
render(<StationCard station={stationWithoutPhoto} isSaved={false} />);
|
||||
|
||||
expect(screen.getByText('Shell Gas Station')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save/Delete Actions', () => {
|
||||
it('should call onSave when bookmark button clicked (not saved)', () => {
|
||||
const onSave = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSave={onSave} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Add to favorites');
|
||||
fireEvent.click(bookmarkButton);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(mockStation);
|
||||
});
|
||||
|
||||
it('should call onDelete when bookmark button clicked (saved)', () => {
|
||||
const onDelete = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={true} onDelete={onDelete} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Remove from favorites');
|
||||
fireEvent.click(bookmarkButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith(mockStation.placeId);
|
||||
});
|
||||
|
||||
it('should show filled bookmark icon when saved', () => {
|
||||
render(<StationCard station={mockStation} isSaved={true} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Remove from favorites');
|
||||
expect(bookmarkButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show outline bookmark icon when not saved', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const bookmarkButton = screen.getByTitle('Add to favorites');
|
||||
expect(bookmarkButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Directions Link', () => {
|
||||
it('should open Google Maps when directions button clicked', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('google.com/maps'),
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
|
||||
it('should encode address in directions URL', () => {
|
||||
render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining(encodeURIComponent(mockStation.address)),
|
||||
'_blank'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Targets', () => {
|
||||
it('should have minimum 44px button heights', () => {
|
||||
const { container } = render(<StationCard station={mockStation} isSaved={false} />);
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
buttons.forEach((button) => {
|
||||
const styles = window.getComputedStyle(button);
|
||||
const minHeight = parseInt(styles.minHeight);
|
||||
expect(minHeight).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card Selection', () => {
|
||||
it('should call onSelect when card is clicked', () => {
|
||||
const onSelect = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
|
||||
|
||||
const card = screen.getByText('Shell Gas Station').closest('.MuiCard-root');
|
||||
if (card) {
|
||||
fireEvent.click(card);
|
||||
expect(onSelect).toHaveBeenCalledWith(mockStation);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not call onSelect when button is clicked', () => {
|
||||
const onSelect = jest.fn();
|
||||
render(<StationCard station={mockStation} isSaved={false} onSelect={onSelect} />);
|
||||
|
||||
const directionsButton = screen.getByTitle('Get directions');
|
||||
fireEvent.click(directionsButton);
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @ai-summary Tests for useStationsSearch hook
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useStationsSearch } from '../../hooks/useStationsSearch';
|
||||
import { stationsApi } from '../../api/stations.api';
|
||||
import { Station } from '../../types/stations.types';
|
||||
|
||||
jest.mock('../../api/stations.api');
|
||||
|
||||
const mockStations: Station[] = [
|
||||
{
|
||||
placeId: 'test-1',
|
||||
name: 'Shell Station',
|
||||
address: '123 Main St',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
rating: 4.2,
|
||||
distance: 250
|
||||
},
|
||||
{
|
||||
placeId: 'test-2',
|
||||
name: 'Chevron Station',
|
||||
address: '456 Market St',
|
||||
latitude: 37.7923,
|
||||
longitude: -122.3989,
|
||||
rating: 4.5,
|
||||
distance: 1200
|
||||
}
|
||||
];
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false }
|
||||
}
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }): React.ReactElement =>
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
children
|
||||
);
|
||||
};
|
||||
|
||||
describe('useStationsSearch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Search Execution', () => {
|
||||
it('should search for stations and return results', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockStations);
|
||||
expect(stationsApi.searchStations).toHaveBeenCalledWith({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 5000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search with custom radius', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 10000
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(stationsApi.searchStations).toHaveBeenCalledWith({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radius: 10000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show pending state during search', () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(mockStations), 100))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
expect(result.current.isPending).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear pending state after success', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isPending).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors', async () => {
|
||||
const error = new Error('API Error');
|
||||
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useStationsSearch(), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call onError callback on failure', async () => {
|
||||
const error = new Error('Network error');
|
||||
(stationsApi.searchStations as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const onError = jest.fn();
|
||||
const { result } = renderHook(() => useStationsSearch({ onError }), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success Callback', () => {
|
||||
it('should call onSuccess callback with data', async () => {
|
||||
(stationsApi.searchStations as jest.Mock).mockResolvedValue(mockStations);
|
||||
|
||||
const onSuccess = jest.fn();
|
||||
const { result } = renderHook(() => useStationsSearch({ onSuccess }), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
|
||||
result.current.mutate({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSuccess).toHaveBeenCalledWith(mockStations);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user