Gas Station Feature
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user