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,857 @@
# Gas Stations Feature - Testing Guide
## Overview
Comprehensive testing guide for the Gas Stations feature. This feature includes unit tests, integration tests, and guidance for writing new tests. All tests follow MotoVaultPro's container-first development approach.
## Test Structure
```
backend/src/features/stations/tests/
├── fixtures/ # Mock data and helpers
│ ├── mock-stations.ts # Sample station data
│ └── mock-google-response.ts # Google API response mocks
├── unit/ # Unit tests
│ ├── stations.service.test.ts # Service layer tests
│ └── google-maps.client.test.ts # External API client tests
└── integration/ # Integration tests
└── stations.api.test.ts # Full API workflow tests
```
## Running Tests
### Container-Based Testing (Recommended)
All tests should be run inside Docker containers to match production environment.
**Run all stations tests**:
```bash
docker compose exec mvp-backend npm test -- features/stations
```
**Run specific test file**:
```bash
docker compose exec mvp-backend npm test -- features/stations/tests/unit/stations.service.test.ts
```
**Run tests in watch mode**:
```bash
docker compose exec mvp-backend npm test -- --watch features/stations
```
**Run tests with coverage**:
```bash
docker compose exec mvp-backend npm test -- --coverage features/stations
```
### Local Development (Optional)
For rapid iteration during test development:
```bash
cd backend
npm test -- features/stations
```
**Note**: Always validate passing tests in containers before committing.
## Test Database Setup
### Test Database Configuration
Tests use a separate test database to avoid polluting development data.
**Environment Variables** (set in docker-compose.yml):
```yaml
NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/motovaultpro_test
```
### Before Running Tests
**Ensure test database exists**:
```bash
# Create test database (one-time setup)
docker compose exec postgres psql -U postgres -c "CREATE DATABASE motovaultpro_test;"
# Run migrations on test database
docker compose exec mvp-backend npm run migrate:test
```
### Test Data Isolation
Each test should:
1. Create its own test data
2. Use unique user IDs
3. Clean up after execution (via beforeEach/afterEach)
**Example**:
```typescript
describe('StationsService', () => {
beforeEach(async () => {
// Clear test data
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
});
afterEach(async () => {
// Additional cleanup if needed
});
});
```
## Writing Unit Tests
### Service Layer Tests
**Location**: `tests/unit/stations.service.test.ts`
**Purpose**: Test business logic in isolation
**Pattern**: Mock external dependencies (repository, Google Maps client)
**Example**:
```typescript
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('StationsService', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
beforeEach(() => {
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn(),
deleteSavedStation: jest.fn()
} as unknown as jest.Mocked<StationsRepository>;
service = new StationsService(mockRepository);
});
it('should search nearby stations and cache results', async () => {
const mockStations = [
{ placeId: 'station-1', name: 'Shell', latitude: 37.7749, longitude: -122.4194 }
];
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(mockStations);
const result = await service.searchNearbyStations({
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}, 'user-123');
expect(result.stations).toHaveLength(1);
expect(mockRepository.cacheStation).toHaveBeenCalledWith(mockStations[0]);
});
});
```
### Repository Layer Tests
**Location**: `tests/unit/stations.repository.test.ts` (create if needed)
**Purpose**: Test SQL query construction and database interaction
**Pattern**: Use in-memory database or transaction rollback
**Example**:
```typescript
import { StationsRepository } from '../../data/stations.repository';
import { pool } from '../../../../core/config/database';
describe('StationsRepository', () => {
let repository: StationsRepository;
beforeEach(() => {
repository = new StationsRepository(pool);
});
it('should save station with user isolation', async () => {
const userId = 'test-user-123';
const placeId = 'test-place-456';
const saved = await repository.saveStation(userId, placeId, {
nickname: 'Test Station'
});
expect(saved.userId).toBe(userId);
expect(saved.stationId).toBe(placeId);
expect(saved.nickname).toBe('Test Station');
});
it('should enforce unique constraint per user', async () => {
const userId = 'test-user-123';
const placeId = 'test-place-456';
await repository.saveStation(userId, placeId, {});
// Attempt duplicate save
await expect(
repository.saveStation(userId, placeId, {})
).rejects.toThrow();
});
});
```
### External Client Tests
**Location**: `tests/unit/google-maps.client.test.ts`
**Purpose**: Test API call construction and response parsing
**Pattern**: Mock axios/fetch, test request format and error handling
**Example**:
```typescript
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import axios from 'axios';
jest.mock('axios');
describe('GoogleMapsClient', () => {
it('should construct correct API request', async () => {
const mockResponse = {
data: {
results: [
{
place_id: 'station-1',
name: 'Shell',
geometry: { location: { lat: 37.7749, lng: -122.4194 } }
}
]
}
};
(axios.get as jest.Mock).mockResolvedValue(mockResponse);
await googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000);
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://maps.googleapis.com/maps/api/place/nearbysearch'),
expect.objectContaining({
params: expect.objectContaining({
location: '37.7749,-122.4194',
radius: 5000,
type: 'gas_station'
})
})
);
});
it('should handle API errors gracefully', async () => {
(axios.get as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(
googleMapsClient.searchNearbyStations(37.7749, -122.4194, 5000)
).rejects.toThrow('API Error');
});
});
```
## Writing Integration Tests
### API Workflow Tests
**Location**: `tests/integration/stations.api.test.ts`
**Purpose**: Test complete request/response flows with real database
**Pattern**: Use Fastify app instance, real JWT, test database
**Example**:
```typescript
import { buildApp } from '../../../../app';
import { FastifyInstance } from 'fastify';
import { pool } from '../../../../core/config/database';
describe('Stations API Integration', () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await buildApp();
// Generate test JWT token
authToken = await generateTestToken({ sub: 'test-user-123' });
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
// Clear test data
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user-123']);
await pool.query('DELETE FROM station_cache');
});
describe('POST /api/stations/search', () => {
it('should search for nearby stations', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}
});
expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body).toHaveProperty('stations');
expect(body.stations).toBeInstanceOf(Array);
expect(body).toHaveProperty('searchLocation');
expect(body).toHaveProperty('searchRadius');
});
it('should return 400 for invalid coordinates', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 999,
longitude: -122.4194
}
});
expect(response.statusCode).toBe(400);
});
it('should return 401 without auth token', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/search',
payload: {
latitude: 37.7749,
longitude: -122.4194
}
});
expect(response.statusCode).toBe(401);
});
});
describe('POST /api/stations/save', () => {
it('should save a station', async () => {
// First, search to populate cache
const searchResponse = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
latitude: 37.7749,
longitude: -122.4194,
radius: 5000
}
});
const searchBody = JSON.parse(searchResponse.body);
const placeId = searchBody.stations[0].placeId;
// Then save station
const saveResponse = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
placeId,
nickname: 'My Favorite Station',
isFavorite: true
}
});
expect(saveResponse.statusCode).toBe(201);
const saveBody = JSON.parse(saveResponse.body);
expect(saveBody.nickname).toBe('My Favorite Station');
expect(saveBody.isFavorite).toBe(true);
});
it('should return 404 if station not in cache', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: {
authorization: `Bearer ${authToken}`
},
payload: {
placeId: 'non-existent-place-id'
}
});
expect(response.statusCode).toBe(404);
});
});
describe('User Isolation', () => {
it('should isolate saved stations by user', async () => {
const user1Token = await generateTestToken({ sub: 'user-1' });
const user2Token = await generateTestToken({ sub: 'user-2' });
// User 1 saves a station
const searchResponse = await app.inject({
method: 'POST',
url: '/api/stations/search',
headers: { authorization: `Bearer ${user1Token}` },
payload: { latitude: 37.7749, longitude: -122.4194 }
});
const placeId = JSON.parse(searchResponse.body).stations[0].placeId;
await app.inject({
method: 'POST',
url: '/api/stations/save',
headers: { authorization: `Bearer ${user1Token}` },
payload: { placeId }
});
// User 2 cannot see User 1's saved station
const user2Response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: { authorization: `Bearer ${user2Token}` }
});
const user2Body = JSON.parse(user2Response.body);
expect(user2Body).toEqual([]);
// User 1 can see their saved station
const user1Response = await app.inject({
method: 'GET',
url: '/api/stations/saved',
headers: { authorization: `Bearer ${user1Token}` }
});
const user1Body = JSON.parse(user1Response.body);
expect(user1Body).toHaveLength(1);
});
});
});
```
## Mock Data and Fixtures
### Creating Test Fixtures
**Location**: `tests/fixtures/mock-stations.ts`
**Purpose**: Reusable test data for all tests
**Example**:
```typescript
export const mockUserId = 'test-user-123';
export const searchCoordinates = {
sanFrancisco: { latitude: 37.7749, longitude: -122.4194 },
newYork: { latitude: 40.7128, longitude: -74.0060 }
};
export const mockStations = [
{
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
name: 'Shell Gas Station - Downtown',
address: '123 Main St, San Francisco, CA 94102',
latitude: 37.7750,
longitude: -122.4195,
rating: 4.2,
photoUrl: 'https://example.com/photo1.jpg',
distance: 150
},
{
placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY5',
name: 'Chevron - Market Street',
address: '456 Market St, San Francisco, CA 94103',
latitude: 37.7755,
longitude: -122.4190,
rating: 4.0,
photoUrl: null,
distance: 300
}
];
export const mockSavedStations = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
userId: mockUserId,
stationId: mockStations[0].placeId,
nickname: 'Work Gas Station',
notes: 'Close to office',
isFavorite: true,
createdAt: new Date('2025-01-15T10:00:00Z'),
updatedAt: new Date('2025-01-15T10:00:00Z'),
deletedAt: null
}
];
```
### Mocking External APIs
**Location**: `tests/fixtures/mock-google-response.ts`
**Purpose**: Simulate Google Maps API responses
**Example**:
```typescript
export const mockGooglePlacesResponse = {
results: [
{
place_id: 'ChIJN1t_tDeuEmsRUsoyG83frY4',
name: 'Shell Gas Station',
vicinity: '123 Main St, San Francisco',
geometry: {
location: { lat: 37.7750, lng: -122.4195 }
},
rating: 4.2,
photos: [
{
photo_reference: 'CmRaAAAA...',
height: 400,
width: 300
}
]
}
],
status: 'OK'
};
export const mockGoogleErrorResponse = {
results: [],
status: 'ZERO_RESULTS',
error_message: 'No results found'
};
```
## Coverage Goals
### Target Coverage
- **Overall Feature Coverage**: >80%
- **Service Layer**: >90% (critical business logic)
- **Repository Layer**: >80% (database operations)
- **Controller Layer**: >70% (error handling)
- **External Client**: >70% (API integration)
### Checking Coverage
```bash
# Generate coverage report
docker compose exec mvp-backend npm test -- --coverage features/stations
# View HTML report (generated in backend/coverage/)
open backend/coverage/lcov-report/index.html
```
### Coverage Exemptions
Lines exempt from coverage requirements:
- Logger statements
- Type guards (if rarely hit)
- Unreachable error handlers
**Mark with comment**:
```typescript
/* istanbul ignore next */
logger.debug('This log is exempt from coverage');
```
## Testing Best Practices
### Test Naming Convention
Use descriptive test names that explain what is being tested:
**Good**:
```typescript
it('should return 404 if station not found in cache')
it('should isolate saved stations by user_id')
it('should sort stations by distance ascending')
```
**Bad**:
```typescript
it('works')
it('test save')
it('error case')
```
### Arrange-Act-Assert Pattern
Structure tests with clear sections:
```typescript
it('should save station with metadata', async () => {
// Arrange
const userId = 'test-user-123';
const placeId = 'station-1';
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
mockRepository.saveStation.mockResolvedValue(mockSavedStations[0]);
// Act
const result = await service.saveStation(placeId, userId, {
nickname: 'Test Station'
});
// Assert
expect(result.nickname).toBe('Test Station');
expect(mockRepository.saveStation).toHaveBeenCalledWith(
userId,
placeId,
{ nickname: 'Test Station' }
);
});
```
### Test Data Cleanup
Always clean up test data to avoid interference:
```typescript
afterEach(async () => {
await pool.query('DELETE FROM saved_stations WHERE user_id LIKE $1', ['test-%']);
await pool.query('DELETE FROM station_cache WHERE created_at < NOW()');
});
```
### Testing Error Scenarios
Test both happy path and error cases:
```typescript
describe('saveStation', () => {
it('should save station successfully', async () => {
// Happy path test
});
it('should throw error if station not in cache', async () => {
mockRepository.getCachedStation.mockResolvedValue(null);
await expect(
service.saveStation('unknown-id', 'user-123')
).rejects.toThrow('Station not found');
});
it('should throw error if database fails', async () => {
mockRepository.getCachedStation.mockResolvedValue(mockStations[0]);
mockRepository.saveStation.mockRejectedValue(new Error('DB Error'));
await expect(
service.saveStation('station-1', 'user-123')
).rejects.toThrow('DB Error');
});
});
```
### Testing User Isolation
Always verify user data isolation:
```typescript
it('should only return stations for authenticated user', async () => {
const user1 = 'user-1';
const user2 = 'user-2';
await repository.saveStation(user1, 'station-1', {});
await repository.saveStation(user2, 'station-2', {});
const user1Stations = await repository.getUserSavedStations(user1);
const user2Stations = await repository.getUserSavedStations(user2);
expect(user1Stations).toHaveLength(1);
expect(user1Stations[0].stationId).toBe('station-1');
expect(user2Stations).toHaveLength(1);
expect(user2Stations[0].stationId).toBe('station-2');
});
```
## CI/CD Integration
### Running Tests in CI Pipeline
**GitHub Actions Example** (`.github/workflows/test.yml`):
```yaml
name: Test Gas Stations Feature
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker
run: docker compose up -d postgres redis
- name: Run migrations
run: docker compose exec mvp-backend npm run migrate:test
- name: Run tests
run: docker compose exec mvp-backend npm test -- features/stations
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./backend/coverage/lcov.info
```
### Pre-Commit Hook
Add to `.git/hooks/pre-commit`:
```bash
#!/bin/sh
echo "Running stations feature tests..."
docker compose exec mvp-backend npm test -- features/stations
if [ $? -ne 0 ]; then
echo "Tests failed. Commit aborted."
exit 1
fi
```
## Debugging Tests
### Enable Verbose Logging
```bash
docker compose exec mvp-backend npm test -- --verbose features/stations
```
### Debug Single Test
```typescript
it.only('should search nearby stations', async () => {
// This test runs in isolation
});
```
### Inspect Test Database
```bash
# Connect to test database
docker compose exec postgres psql -U postgres -d motovaultpro_test
# Query test data
SELECT * FROM saved_stations WHERE user_id LIKE 'test-%';
SELECT * FROM station_cache;
```
### View Test Logs
```bash
docker compose logs mvp-backend | grep -i "test"
```
## Common Test Failures
### "Station not found" Error
**Cause**: Station not in cache before save attempt
**Fix**: Ensure search populates cache first:
```typescript
await service.searchNearbyStations({ latitude, longitude }, userId);
await service.saveStation(placeId, userId);
```
### "Unique constraint violation"
**Cause**: Test data not cleaned up between tests
**Fix**: Add proper cleanup in beforeEach:
```typescript
beforeEach(async () => {
await pool.query('DELETE FROM saved_stations WHERE user_id = $1', ['test-user']);
});
```
### "JWT token invalid"
**Cause**: Test token expired or malformed
**Fix**: Use proper test token generation:
```typescript
const token = await generateTestToken({ sub: 'test-user', exp: Date.now() + 3600 });
```
### "Circuit breaker open"
**Cause**: Too many failed Google API calls in tests
**Fix**: Mock Google client to avoid real API calls:
```typescript
jest.mock('../../external/google-maps/google-maps.client');
```
## Adding New Tests
### Checklist for New Test Files
- [ ] Create in appropriate directory (unit/ or integration/)
- [ ] Import necessary fixtures from tests/fixtures/
- [ ] Mock external dependencies (repository, Google client)
- [ ] Add beforeEach/afterEach cleanup
- [ ] Follow naming conventions
- [ ] Test happy path
- [ ] Test error scenarios
- [ ] Test user isolation
- [ ] Run in container to verify
- [ ] Check coverage impact
### Template for New Unit Test
```typescript
import { /* imports */ } from '../../domain/stations.service';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('FeatureName', () => {
let service: YourService;
let mockDependency: jest.Mocked<Dependency>;
beforeEach(() => {
jest.clearAllMocks();
// Setup mocks
service = new YourService(mockDependency);
});
describe('methodName', () => {
it('should handle happy path', async () => {
// Arrange
// Act
// Assert
});
it('should handle error case', async () => {
// Arrange
// Act
// Assert
});
});
});
```
## References
- Jest Documentation: https://jestjs.io/docs/getting-started
- Fastify Testing: https://www.fastify.io/docs/latest/Guides/Testing/
- Feature README: `/backend/src/features/stations/README.md`
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- API Documentation: `/backend/src/features/stations/docs/API.md`