# 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,
    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<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`
