Files
motovaultpro/backend/src/features/stations/docs/TESTING.md
Eric Gullickson bcb1cea311 Security fix: Implement Google Maps API photo proxy (Fix 3)
Completed HIGH severity security fix (CVSS 6.5) to prevent Google Maps
API key exposure to frontend clients.

Issue: API key was embedded in photo URLs sent to frontend, allowing
potential abuse and quota exhaustion.

Solution: Implemented backend proxy endpoint for photos.

Backend Changes:
- google-maps.client.ts: Changed photoUrl to photoReference, added fetchPhoto()
- stations.types.ts: Updated type definition (photoUrl → photoReference)
- stations.controller.ts: Added getStationPhoto() proxy method
- stations.routes.ts: Added GET /api/stations/photo/:reference route
- stations.service.ts: Updated to use photoReference
- stations.repository.ts: Updated database queries and mappings
- admin controllers/services: Updated for consistency
- Created migration 003 to rename photo_url column

Frontend Changes:
- stations.types.ts: Updated type definition (photoUrl → photoReference)
- photo-utils.ts: NEW - Helper to generate proxy URLs
- StationCard.tsx: Use photoReference with helper function

Tests & Docs:
- Updated mock data to use photoReference
- Updated test expectations for proxy URLs
- Updated API.md and TESTING.md documentation

Database Migration:
- 003_rename_photo_url_to_photo_reference.sql: Renames column in station_cache

Security Benefits:
- API key never sent to frontend
- All photo requests proxied through authenticated endpoint
- Photos cached for 24 hours (Cache-Control header)
- No client-side API key exposure

Files modified: 16 files
New files: 2 (photo-utils.ts, migration 003)

Status: All 3 P0 security fixes now complete
- Fix 1: crypto.randomBytes() ✓
- Fix 2: Magic byte validation ✓
- Fix 3: API key proxy ✓

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 09:56:33 -06:00

21 KiB

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

All tests should be run inside Docker containers to match production environment.

Run all stations tests:

docker compose exec mvp-backend npm test -- features/stations

Run specific test file:

docker compose exec mvp-backend npm test -- features/stations/tests/unit/stations.service.test.ts

Run tests in watch mode:

docker compose exec mvp-backend npm test -- --watch features/stations

Run tests with coverage:

docker compose exec mvp-backend npm test -- --coverage features/stations

Local Development (Optional)

For rapid iteration during test development:

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):

NODE_ENV: test
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/motovaultpro_test

Before Running Tests

Ensure test database exists:

# 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:

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:

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:

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:

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:

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:

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:

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

# 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:

/* 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:

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:

it('works')
it('test save')
it('error case')

Arrange-Act-Assert Pattern

Structure tests with clear sections:

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:

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:

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:

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):

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:

#!/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

docker compose exec mvp-backend npm test -- --verbose features/stations

Debug Single Test

it.only('should search nearby stations', async () => {
  // This test runs in isolation
});

Inspect Test Database

# 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

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:

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:

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:

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:

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

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