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