# 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; beforeEach(() => { mockRepository = { cacheStation: jest.fn().mockResolvedValue(undefined), getCachedStation: jest.fn(), saveStation: jest.fn(), getUserSavedStations: jest.fn(), deleteSavedStation: jest.fn() } as unknown as jest.Mocked; 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, photoReference: 'mock-photo-reference-1', 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, photoReference: 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; 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`