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,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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});
});