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

@@ -1,37 +1,237 @@
# Stations Feature Capsule
# Gas Stations Feature
## Quick Summary (50 tokens)
Search nearby gas stations via Google Maps and manage users' saved stations with user-owned saved lists. Caches search results for 1 hour. JWT required for all endpoints.
Complete gas station discovery and management feature with Google Maps integration, caching, and user favorites.
## API Endpoints (JWT required)
- `POST /api/stations/search` — Search nearby stations
- `POST /api/stations/save` — Save a station to user's favorites
- `GET /api/stations/saved` — List saved stations for the user
- `DELETE /api/stations/saved/:placeId` — Remove a saved station
## Quick Summary
## Structure
- **api/** - HTTP endpoints, routes, validators
- **domain/** - Business logic, types, rules
- **data/** - Repository, database queries
- **migrations/** - Feature-specific schema
- **external/** - External API integrations
- **events/** - Event handlers
- **tests/** - All feature tests
- **docs/** - Detailed documentation
Search nearby gas stations via Google Maps API. Users can view stations on a map, save favorites with custom notes, and integrate station data into fuel logging. Search results cached for 1 hour. JWT required for all endpoints. User data isolation via `user_id`.
## Dependencies
- Internal: core/auth, core/cache
- External: Google Maps API (Places)
- Database: stations table (see `docs/DATABASE-SCHEMA.md`)
## Implementation Phases Status
## Quick Commands
| Phase | Feature | Status |
|-------|---------|--------|
| 1 | Frontend K8s-aligned secrets pattern | ✅ Complete |
| 2 | Backend improvements (circuit breaker, tests) | ✅ Complete |
| 3 | Frontend foundation (types, API, hooks) | ✅ Complete |
| 4 | Frontend components (card, list, form, map) | ✅ Complete |
| 5 | Desktop page with map/search layout | ✅ Complete |
| 6 | Mobile screen with tab navigation | 🔄 In Progress |
| 7 | Fuel logs integration (StationPicker) | ⏳ Pending |
| 8 | Testing (unit, integration, E2E) | ⏳ Pending |
| 9 | Documentation (API, setup, troubleshooting) | ⏳ Pending |
| 10 | Validation & Polish (lint, tests, manual) | ⏳ Pending |
| 11 | Deployment preparation & checklist | ⏳ Pending |
## Architecture
### Backend Structure
```
features/stations/
├── api/ # HTTP handlers and routes
│ ├── stations.controller.ts
│ └── stations.routes.ts
├── domain/ # Business logic
│ ├── stations.service.ts
│ └── stations.types.ts
├── data/ # Database access
│ └── stations.repository.ts
├── external/google-maps/ # External API
│ ├── google-maps.client.ts
│ ├── google-maps.circuit-breaker.ts
│ └── google-maps.types.ts
├── jobs/ # Scheduled tasks
│ └── cache-cleanup.job.ts
├── migrations/ # Database schema
│ ├── 001_create_stations_tables.sql
│ └── 002_add_indexes.sql
├── tests/ # Test suite
│ ├── fixtures/
│ ├── unit/
│ └── integration/
└── index.ts # Feature exports
```
### Frontend Structure
```
features/stations/
├── types/ # TypeScript definitions
│ └── stations.types.ts
├── api/ # API client
│ └── stations.api.ts
├── hooks/ # React Query hooks
│ ├── useStationsSearch.ts
│ ├── useSavedStations.ts
│ ├── useSaveStation.ts
│ ├── useDeleteStation.ts
│ └── useGeolocation.ts
├── utils/ # Utilities
│ ├── distance.ts
│ ├── maps-loader.ts
│ └── map-utils.ts
├── components/ # React components
│ ├── StationCard.tsx
│ ├── StationsList.tsx
│ ├── SavedStationsList.tsx
│ ├── StationsSearchForm.tsx
│ └── StationMap.tsx
├── pages/ # Page layouts
│ └── StationsPage.tsx # Desktop
├── mobile/ # Mobile layouts (Phase 6)
│ └── StationsMobileScreen.tsx
└── __tests__/ # Tests (Phase 8)
```
## API Endpoints (All require JWT)
```
POST /api/stations/search
Body: { latitude, longitude, radius?, fuelType? }
Response: { stations[], searchLocation, searchRadius, timestamp }
POST /api/stations/save
Body: { placeId, nickname?, notes?, isFavorite? }
Response: SavedStation
GET /api/stations/saved
Response: SavedStation[]
GET /api/stations/saved/:placeId
Response: SavedStation | 404
PATCH /api/stations/saved/:placeId
Body: { nickname?, notes?, isFavorite? }
Response: SavedStation
DELETE /api/stations/saved/:placeId
Response: 204
```
## Database Schema
### station_cache
Temporary Google Places results (auto-cleanup after 24h)
```sql
- id: UUID PRIMARY KEY
- place_id: VARCHAR UNIQUE
- name: VARCHAR
- address: VARCHAR
- latitude: DECIMAL
- longitude: DECIMAL
- rating: DECIMAL
- photo_url: VARCHAR
- created_at: TIMESTAMP
```
### saved_stations
User's favorite stations with metadata
```sql
- id: UUID PRIMARY KEY
- user_id: VARCHAR (indexed)
- place_id: VARCHAR (indexed)
- nickname: VARCHAR
- notes: TEXT
- is_favorite: BOOLEAN
- created_at: TIMESTAMP
- updated_at: TIMESTAMP
- deleted_at: TIMESTAMP (soft delete)
- UNIQUE(user_id, place_id)
```
## Key Features Implemented
**Security**
- User-scoped data isolation via `user_id` filtering
- Parameterized queries (no SQL injection)
- JWT authentication required on all endpoints
- K8s-aligned secrets pattern (never in environment variables)
**Performance**
- Redis caching with 1-hour TTL
- Circuit breaker for resilience (10s timeout, 50% threshold)
- Database indexes on user_id and place_id
- Scheduled cache cleanup (24h auto-expiry)
- Lazy-loaded Google Maps API in browser
**User Experience**
- Real-time browser geolocation with permission handling
- Touch-friendly 44px minimum button heights
- Responsive map with auto-fit bounds
- Saved stations with custom nicknames and notes
- One-click directions to Google Maps
## Testing
### Run Tests
```bash
# Run feature tests
# Backend feature tests
cd backend
npm test -- features/stations
# Run feature migrations
npm run migrate:feature stations
# Frontend component tests (Phase 8)
cd frontend
npm test -- stations
# E2E tests (Phase 8)
npm run e2e
```
## Notes
- Search payload and saved schema to be finalized; align with Google Places best practices and platform quotas. Caching policy: 1 hour TTL (key `stations:search:{query}`).
### Coverage Goals
- Backend: >80% coverage
- Frontend components: >80% coverage
- All critical paths tested
## Deployment
### Prerequisites
1. Google Maps API key with Places API enabled
2. PostgreSQL database with migrations applied
3. Redis cache service running
4. Docker for containerization
### Setup
```bash
# Create secrets directory
mkdir -p ./secrets/app
# Add Google Maps API key
echo "YOUR_API_KEY_HERE" > ./secrets/app/google-maps-api-key.txt
# Build and start
make setup
make logs
# Verify
curl http://localhost:3001/health
```
### Verification
```bash
# Check secrets mounted
docker compose exec mvp-frontend cat /run/secrets/google-maps-api-key
# Check config generated
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Test API endpoint
curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/api/stations/saved
```
## Next Steps (Phases 6-11)
### Phase 6: Mobile Implementation
Create mobile bottom-tab navigation screen with Search, Saved, Map tabs.
### Phase 7: Fuel Logs Integration
Add StationPicker component with autocomplete to FuelLogForm.
### Phases 8-11: Testing, Docs, Validation, Deployment
Follow the detailed plan in `/docs/GAS-STATIONS.md`.
## References
- Full implementation plan: `/docs/GAS-STATIONS.md`
- Runtime config pattern: `/frontend/docs/RUNTIME-CONFIG.md`
- Design patterns: See Platform feature (`backend/src/features/platform/`)
- Component patterns: See Vehicles feature (`frontend/src/features/vehicles/`)

View File

@@ -0,0 +1,676 @@
# Gas Stations Feature - API Documentation
## Overview
Complete API reference for the Gas Stations feature. All endpoints require JWT authentication via Auth0. User data is automatically isolated by user_id extracted from the JWT token.
## Authentication
**Method**: JWT Bearer Token
**Header Format**:
```
Authorization: Bearer {jwt_token}
```
**Token Source**: Auth0 authentication flow
**User Identification**: Extracted from token's `sub` claim
## Base URL
**Development**: `http://localhost:3001/api/stations`
**Production**: `https://motovaultpro.com/api/stations`
## Endpoints
### Search Nearby Stations
Search for gas stations near a location using Google Maps Places API.
**Endpoint**: `POST /api/stations/search`
**Authentication**: Required (JWT)
**Request Body**:
```json
{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000,
"fuelType": "regular"
}
```
**Request Schema**:
| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| latitude | number | Yes | -90 to 90 | Search center latitude |
| longitude | number | -180 to 180 | Yes | Search center longitude |
| radius | number | No | 100 to 50000 | Search radius in meters (default: 5000) |
| fuelType | string | No | - | Fuel type filter (e.g., "regular", "premium", "diesel") |
**Response**: `200 OK`
```json
{
"stations": [
{
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA 94102",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?...",
"distance": 150
}
],
"searchLocation": {
"latitude": 37.7749,
"longitude": -122.4194
},
"searchRadius": 5000,
"timestamp": "2025-01-15T10:30:00.000Z"
}
```
**Response Schema**:
| Field | Type | Description |
|-------|------|-------------|
| stations | Station[] | Array of nearby stations, sorted by distance |
| stations[].placeId | string | Unique Google Place ID |
| stations[].name | string | Station name |
| stations[].address | string | Full address |
| stations[].latitude | number | Station latitude |
| stations[].longitude | number | Station longitude |
| stations[].rating | number | Google rating (0-5) |
| stations[].photoUrl | string | Photo URL (nullable) |
| stations[].distance | number | Distance from search location (meters) |
| searchLocation | object | Original search coordinates |
| searchRadius | number | Actual search radius used |
| timestamp | string | ISO 8601 timestamp |
**Error Responses**:
**400 Bad Request** - Invalid input
```json
{
"error": "Bad Request",
"message": "Latitude and longitude are required"
}
```
**401 Unauthorized** - Missing or invalid JWT
```json
{
"error": "Unauthorized",
"message": "Valid JWT token required"
}
```
**502 Bad Gateway** - Google Maps API unavailable
```json
{
"error": "Service unavailable",
"message": "Unable to search stations. Please try again later."
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to search stations"
}
```
**Rate Limits**: Inherits Google Maps API limits (1000 requests/day free tier)
**Performance**: 500-1500ms (includes Google API call)
**Example cURL**:
```bash
curl -X POST https://motovaultpro.com/api/stations/search \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000
}'
```
---
### Save Station
Save a station to user's favorites with optional metadata.
**Endpoint**: `POST /api/stations/save`
**Authentication**: Required (JWT)
**Request Body**:
```json
{
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "My Regular Station",
"notes": "Always has cheapest premium gas",
"isFavorite": true
}
```
**Request Schema**:
| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| placeId | string | Yes | Must exist in cache | Google Place ID from search results |
| nickname | string | No | Max 255 chars | Custom name for station |
| notes | string | No | Max 5000 chars | Personal notes |
| isFavorite | boolean | No | Default: false | Mark as favorite |
**Response**: `201 Created`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "auth0|123456789",
"stationId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "My Regular Station",
"notes": "Always has cheapest premium gas",
"isFavorite": true,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z",
"station": {
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA 94102",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
}
}
```
**Response Schema**:
| Field | Type | Description |
|-------|------|-------------|
| id | string (UUID) | Saved station record ID |
| userId | string | User ID from JWT token |
| stationId | string | Google Place ID |
| nickname | string | Custom name (nullable) |
| notes | string | Personal notes (nullable) |
| isFavorite | boolean | Favorite flag |
| createdAt | string | ISO 8601 timestamp |
| updatedAt | string | ISO 8601 timestamp |
| station | Station | Enriched station data from cache |
**Error Responses**:
**400 Bad Request** - Missing placeId
```json
{
"error": "Bad Request",
"message": "Place ID is required"
}
```
**404 Not Found** - Station not in cache
```json
{
"error": "Not Found",
"message": "Station not found. Please search for stations first."
}
```
**409 Conflict** - Station already saved
```json
{
"error": "Conflict",
"message": "Station already saved"
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to save station"
}
```
**Rate Limits**: None (database operation)
**Performance**: 50-100ms
**Example cURL**:
```bash
curl -X POST https://motovaultpro.com/api/stations/save \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "My Regular Station",
"isFavorite": true
}'
```
---
### Get Saved Stations
Retrieve all stations saved by the authenticated user.
**Endpoint**: `GET /api/stations/saved`
**Authentication**: Required (JWT)
**Request Parameters**: None
**Response**: `200 OK`
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "auth0|123456789",
"stationId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "My Regular Station",
"notes": "Always has cheapest premium gas",
"isFavorite": true,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z",
"station": {
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA 94102",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
}
}
]
```
**Response Schema**: Array of saved stations (same schema as POST /save response)
**Error Responses**:
**401 Unauthorized**
```json
{
"error": "Unauthorized",
"message": "Valid JWT token required"
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to get saved stations"
}
```
**Rate Limits**: None
**Performance**: 50-100ms
**Example cURL**:
```bash
curl -X GET https://motovaultpro.com/api/stations/saved \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
---
### Get Specific Saved Station
Retrieve a specific saved station by Google Place ID.
**Endpoint**: `GET /api/stations/saved/:placeId`
**Authentication**: Required (JWT)
**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| placeId | string | Yes | Google Place ID |
**Response**: `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "auth0|123456789",
"stationId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "My Regular Station",
"notes": "Always has cheapest premium gas",
"isFavorite": true,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z",
"station": {
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA 94102",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
}
}
```
**Error Responses**:
**404 Not Found** - Station not saved or doesn't belong to user
```json
{
"error": "Not Found",
"message": "Saved station not found"
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to get saved station"
}
```
**Rate Limits**: None
**Performance**: 50-100ms
**Example cURL**:
```bash
curl -X GET https://motovaultpro.com/api/stations/saved/ChIJN1t_tDeuEmsRUsoyG83frY4 \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
---
### Update Saved Station
Update metadata for a saved station.
**Endpoint**: `PATCH /api/stations/saved/:placeId`
**Authentication**: Required (JWT)
**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| placeId | string | Yes | Google Place ID |
**Request Body** (all fields optional):
```json
{
"nickname": "Updated Nickname",
"notes": "Updated notes",
"isFavorite": false
}
```
**Request Schema**:
| Field | Type | Required | Constraints | Description |
|-------|------|----------|-------------|-------------|
| nickname | string | No | Max 255 chars | Updated custom name |
| notes | string | No | Max 5000 chars | Updated notes |
| isFavorite | boolean | No | - | Updated favorite flag |
**Response**: `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "auth0|123456789",
"stationId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"nickname": "Updated Nickname",
"notes": "Updated notes",
"isFavorite": false,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T11:00:00.000Z",
"station": {
"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA 94102",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"photoUrl": "https://maps.googleapis.com/maps/api/place/photo?..."
}
}
```
**Error Responses**:
**404 Not Found** - Station not saved or doesn't belong to user
```json
{
"error": "Not Found",
"message": "Saved station not found"
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to update saved station"
}
```
**Rate Limits**: None
**Performance**: 50-100ms
**Example cURL**:
```bash
curl -X PATCH https://motovaultpro.com/api/stations/saved/ChIJN1t_tDeuEmsRUsoyG83frY4 \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"nickname": "Updated Nickname",
"isFavorite": false
}'
```
---
### Delete Saved Station
Remove a station from user's saved list.
**Endpoint**: `DELETE /api/stations/saved/:placeId`
**Authentication**: Required (JWT)
**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| placeId | string | Yes | Google Place ID |
**Response**: `204 No Content` (empty body)
**Error Responses**:
**404 Not Found** - Station not saved or doesn't belong to user
```json
{
"error": "Not Found",
"message": "Saved station not found"
}
```
**500 Internal Server Error**
```json
{
"error": "Internal server error",
"message": "Failed to remove saved station"
}
```
**Rate Limits**: None
**Performance**: 50-100ms
**Example cURL**:
```bash
curl -X DELETE https://motovaultpro.com/api/stations/saved/ChIJN1t_tDeuEmsRUsoyG83frY4 \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
---
## Common Patterns
### User Data Isolation
All endpoints automatically filter by user_id extracted from JWT token. Users can only access their own saved stations.
**Example**:
- User A saves a station (placeId: ABC123)
- User B tries to GET /api/stations/saved/ABC123
- Returns 404 (not found) because it doesn't belong to User B
### Soft Deletes
Deleted saved stations use soft delete pattern (deleted_at timestamp). This preserves referential integrity with fuel logs and other features.
### Station Cache Workflow
1. User searches for stations (POST /search)
2. Stations are cached in database for 24 hours
3. User saves a station (POST /save)
4. Service retrieves cached data and creates saved record
5. Future requests (GET /saved) enrich with cached data
**Important**: Users must search before saving. Saving without prior search returns 404.
### Error Code Summary
| Status Code | Meaning | Common Causes |
|-------------|---------|---------------|
| 200 | Success | Valid request processed |
| 201 | Created | Station saved successfully |
| 204 | No Content | Station deleted successfully |
| 400 | Bad Request | Missing required fields, invalid input |
| 401 | Unauthorized | Missing/invalid JWT token |
| 404 | Not Found | Station not found, not cached, or not owned by user |
| 409 | Conflict | Duplicate save attempt |
| 500 | Internal Server Error | Database error, unexpected exception |
| 502 | Bad Gateway | Google Maps API unavailable |
## Testing the API
### Get JWT Token
```bash
# Use Auth0 authentication flow or get test token
TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
### Complete Workflow Example
```bash
# 1. Search for stations
curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000
}' | jq
# 2. Save a station from search results
PLACE_ID="ChIJN1t_tDeuEmsRUsoyG83frY4"
curl -X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"placeId\": \"$PLACE_ID\",
\"nickname\": \"My Favorite Station\",
\"isFavorite\": true
}" | jq
# 3. Get all saved stations
curl -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $TOKEN" | jq
# 4. Update saved station
curl -X PATCH http://localhost:3001/api/stations/saved/$PLACE_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"notes": "Cheapest gas on my route"
}' | jq
# 5. Delete saved station
curl -X DELETE http://localhost:3001/api/stations/saved/$PLACE_ID \
-H "Authorization: Bearer $TOKEN"
```
## Rate Limiting
### Google Maps API Limits
- **Free Tier**: 1,000 requests per day
- **Cost**: $5 per 1,000 additional requests (Places Nearby Search)
- **Place Details**: $17 per 1,000 requests
### Application Limits
No application-level rate limiting currently implemented. Consider implementing:
- Per-user rate limits (e.g., 100 searches per hour)
- IP-based throttling for abuse prevention
- Exponential backoff on failures
## Security Considerations
### JWT Validation
All endpoints validate JWT signature, expiration, and issuer. Tokens must be issued by configured Auth0 tenant.
### SQL Injection Prevention
All database queries use parameterized statements. Never concatenate user input into SQL strings.
### Secrets Management
Google Maps API key is loaded from `/run/secrets/google-maps-api-key` at container startup. Never log or expose the API key.
### CORS Configuration
API endpoints are configured to accept requests from:
- https://motovaultpro.com (production)
- http://localhost:3000 (development)
## Versioning
**Current Version**: v1 (implicit in /api/stations path)
**Breaking Changes Policy**:
- Version bump required for:
- Field removal
- Field type changes
- New required fields
- Changed error codes
- Authentication changes
- Minimum 30-day deprecation notice
- Both versions supported during transition
## Support
For API issues:
1. Check container logs: `docker compose logs mvp-backend`
2. Verify JWT token is valid
3. Check Google Maps API key is configured
4. Review circuit breaker status in logs
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Feature README: `/backend/src/features/stations/README.md`

View File

@@ -0,0 +1,491 @@
# Gas Stations Feature - Architecture
## Overview
The Gas Stations feature enables users to search for nearby gas stations using Google Maps Places API, save favorite stations with metadata, and integrate station data into fuel logging workflows. The feature follows MotoVaultPro's feature capsule pattern with complete isolation and user-scoped data.
## System Design
### High-Level Architecture
```
User Request (JWT)
|
v
API Layer (Controller + Routes)
|
v
Domain Layer (Service)
|
+---> External API (Google Maps Client)
| |
| v
| Circuit Breaker
| |
| v
| Google Places API
|
+---> Data Layer (Repository)
|
v
PostgreSQL
```
### Component Responsibilities
#### API Layer
- **stations.controller.ts**: HTTP request/response handling
- Request validation with Zod schemas
- JWT authentication enforcement
- User ID extraction from token
- Error response formatting
- **stations.routes.ts**: Route definitions
- POST /api/stations/search - Search nearby stations
- POST /api/stations/save - Save station with metadata
- GET /api/stations/saved - Get user's saved stations
- GET /api/stations/saved/:placeId - Get specific saved station
- PATCH /api/stations/saved/:placeId - Update saved station
- DELETE /api/stations/saved/:placeId - Remove saved station
#### Domain Layer
- **stations.service.ts**: Core business logic
- Search orchestration
- Station caching strategy
- User data isolation
- Distance sorting
- Data enrichment (combining saved + cached data)
- **stations.types.ts**: Type definitions
- Request/response interfaces
- Domain models
- Validation rules
#### Data Layer
- **stations.repository.ts**: Database operations
- Station cache CRUD operations
- Saved stations CRUD operations
- User-scoped queries
- Soft delete implementation
- Transaction support
#### External Integration
- **google-maps.client.ts**: Google Places API wrapper
- Places Nearby Search
- Place Details retrieval
- Error handling
- Response transformation
- **google-maps.circuit-breaker.ts**: Resilience pattern
- 10s timeout per request
- 50% error threshold triggers open
- 30s reset timeout
- Volume threshold: 10 requests minimum
- State change logging
## Data Flow
### Search Flow
1. User requests nearby stations (latitude, longitude, radius)
2. Controller validates JWT and request body
3. Service calls Google Maps client through circuit breaker
4. Google Maps returns nearby stations
5. Service caches each station in PostgreSQL
6. Service sorts stations by distance
7. Controller returns enriched response
### Save Station Flow
1. User requests to save a station (placeId, metadata)
2. Controller validates JWT and request body
3. Service retrieves station from cache
4. Service inserts into saved_stations table (user_id scoped)
5. Service enriches saved record with cached data
6. Controller returns combined result
### List Saved Stations Flow
1. User requests their saved stations
2. Controller validates JWT
3. Service queries saved_stations by user_id
4. Service enriches each record with cached station data
5. Controller returns array of enriched stations
## External Dependencies
### Google Maps Places API
**Required APIs**:
- Places API (Nearby Search)
- Places API (Place Details)
**Authentication**: API key loaded from /run/secrets/google-maps-api-key
**Rate Limits**:
- 1,000 requests per day (free tier)
- Cost per additional 1,000 requests varies by API
- See GOOGLE-MAPS-SETUP.md for detailed pricing
**Error Handling**:
- Circuit breaker protects against cascading failures
- Graceful degradation when API unavailable
- User-friendly error messages
### PostgreSQL
**Tables Used**:
- station_cache: Temporary storage for Google Places results
- saved_stations: User's favorite stations with metadata
**Connection Pool**: Managed by core database service
## Caching Strategy
### Station Cache (PostgreSQL)
**Purpose**: Store Google Places results to avoid repeated API calls
**TTL**: 24 hours (auto-cleanup via scheduled job)
**Cache Key**: place_id (unique identifier from Google)
**Data Stored**:
- place_id, name, address
- latitude, longitude
- rating, photo_url
- created_at timestamp
**Eviction Policy**:
- Scheduled job runs every hour
- Deletes records older than 24 hours
- No manual invalidation
**Why PostgreSQL, not Redis?**:
- Station data is relatively large (multiple fields)
- 24-hour retention doesn't require ultra-fast access
- Simplifies architecture (no Redis key management)
- Allows SQL queries (future analytics)
### Saved Stations (PostgreSQL)
**Purpose**: Persistent storage of user favorites
**TTL**: Indefinite (user manages via delete)
**User Isolation**: All queries filter by user_id
**Soft Deletes**: deleted_at timestamp preserves referential integrity
## Circuit Breaker Pattern
### Configuration
```typescript
{
timeout: 10000, // 10s max wait
errorThresholdPercentage: 50, // Open at 50% error rate
resetTimeout: 30000, // 30s before retry
volumeThreshold: 10, // Min requests before opening
rollingCountTimeout: 10000 // 10s window for counting
}
```
### States
1. **CLOSED** (Normal Operation)
- Requests pass through to Google Maps API
- Errors are tracked in rolling window
- Opens if error rate exceeds 50%
2. **OPEN** (Failure Mode)
- Requests fail immediately without calling API
- Returns null to caller
- After 30s, transitions to HALF_OPEN
3. **HALF_OPEN** (Testing Recovery)
- Single test request passes through
- Success closes circuit
- Failure reopens circuit
### Monitoring
All state changes logged via Winston:
- `Circuit breaker opened for Google Maps {operation}`
- `Circuit breaker half-open for Google Maps {operation}`
- `Circuit breaker closed for Google Maps {operation}`
## Database Schema
### station_cache
```sql
CREATE TABLE station_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
place_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(500),
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
rating DECIMAL(2, 1),
photo_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_place_id (place_id),
INDEX idx_created_at (created_at)
);
```
**Indexes**:
- place_id: Fast lookup during save/enrich operations
- created_at: Efficient cleanup job queries
### saved_stations
```sql
CREATE TABLE saved_stations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
place_id VARCHAR(255) NOT NULL,
nickname VARCHAR(255),
notes TEXT,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
UNIQUE(user_id, place_id),
INDEX idx_user_id (user_id),
INDEX idx_place_id (place_id),
INDEX idx_deleted_at (deleted_at)
);
```
**Indexes**:
- user_id: Fast user-scoped queries (most common operation)
- place_id: Fast lookups when updating/deleting
- deleted_at: Efficient soft delete filtering
- UNIQUE(user_id, place_id): Prevents duplicate saves
**Constraints**:
- user_id NOT NULL: All data must be user-scoped
- place_id NOT NULL: Must reference valid Google Place
- UNIQUE constraint: One save per user per station
## Error Handling
### Validation Errors (400)
**Triggers**:
- Missing required fields
- Invalid latitude/longitude
- Invalid radius
- Malformed placeId
**Response Format**:
```json
{
"error": "Validation failed",
"details": [
{ "field": "latitude", "message": "Must be between -90 and 90" }
]
}
```
### Authentication Errors (401)
**Triggers**:
- Missing JWT token
- Expired token
- Invalid token signature
**Response Format**:
```json
{
"error": "Unauthorized",
"message": "Valid JWT token required"
}
```
### Not Found Errors (404)
**Triggers**:
- Saved station doesn't exist
- Station not in cache
- User doesn't own saved station
**Response Format**:
```json
{
"error": "Not found",
"message": "Saved station not found"
}
```
### External API Errors (502)
**Triggers**:
- Google Maps API timeout
- Google Maps API rate limit
- Circuit breaker open
**Response Format**:
```json
{
"error": "Service unavailable",
"message": "Unable to search stations. Please try again later."
}
```
### Server Errors (500)
**Triggers**:
- Database connection failure
- Unexpected exceptions
- Code bugs
**Response Format**:
```json
{
"error": "Internal server error",
"message": "An unexpected error occurred"
}
```
## Security
### Authentication
**Method**: JWT (Auth0)
**Enforcement**: All endpoints require valid JWT
**User Extraction**: User ID from token's `sub` claim
### Authorization
**User Data Isolation**: All queries filter by user_id
**Ownership Validation**:
- GET /saved/:placeId checks user_id matches
- PATCH /saved/:placeId checks user_id matches
- DELETE /saved/:placeId checks user_id matches
### SQL Injection Prevention
**Strategy**: Parameterized queries only
**Example**:
```typescript
// CORRECT (parameterized)
await pool.query(
'SELECT * FROM saved_stations WHERE user_id = $1',
[userId]
);
// NEVER DO THIS (concatenation)
await pool.query(
`SELECT * FROM saved_stations WHERE user_id = '${userId}'`
);
```
### Secrets Management
**API Key Storage**: /run/secrets/google-maps-api-key (file mount)
**Access Pattern**: Read once at service startup
**Never Log**: Secrets never appear in logs
## Performance
### Expected Latencies
- **POST /search**: 500-1500ms (Google API call)
- **POST /save**: 50-100ms (database insert)
- **GET /saved**: 50-100ms (database query)
- **PATCH /saved/:id**: 50-100ms (database update)
- **DELETE /saved/:id**: 50-100ms (database delete)
### Optimization Strategies
1. **Cache Station Data**: Avoid repeated Google API calls
2. **Index User Queries**: Fast user_id filtering
3. **Sort in Memory**: Distance sorting after fetch (small datasets)
4. **Circuit Breaker**: Fail fast when API unavailable
5. **Connection Pooling**: Reuse database connections
### Scaling Considerations
- **Database**: Index tuning for large user bases
- **Google API**: Rate limiting and quota monitoring
- **Circuit Breaker**: Adjust thresholds based on traffic
- **Cache Cleanup**: Schedule during low-traffic periods
## Testing Strategy
### Unit Tests
- Service layer business logic
- Circuit breaker behavior
- Repository query construction
- Input validation
### Integration Tests
- Complete API workflows
- Database transactions
- Error scenarios
- User isolation
### External API Mocking
- Mock Google Maps responses
- Simulate API failures
- Test circuit breaker states
See TESTING.md for detailed testing guide.
## Future Enhancements
### Potential Improvements
1. **Real-time Fuel Prices**: Integrate GasBuddy or similar API
2. **Route Optimization**: Find cheapest stations along route
3. **Price Alerts**: Notify users of price drops
4. **Analytics Dashboard**: Spending trends, favorite stations
5. **Social Features**: Share favorite stations, ratings
6. **Offline Support**: Cache recent searches for offline access
### Breaking Changes
Any changes to API contracts require:
1. Version bump in API path (e.g., /api/v2/stations)
2. Frontend update coordination
3. Migration guide for existing data
4. Deprecation notice (minimum 30 days)
## Troubleshooting
### Common Issues
**Symptom**: "Unable to search stations"
**Cause**: Circuit breaker open or Google API key invalid
**Solution**: Check logs for circuit breaker state, verify API key
**Symptom**: "Station not found" when saving
**Cause**: Station not in cache (search first)
**Solution**: User must search before saving
**Symptom**: Slow search responses
**Cause**: Google API latency or rate limiting
**Solution**: Monitor circuit breaker metrics, check API quotas
**Symptom**: Duplicate saved stations
**Cause**: Race condition or UNIQUE constraint failure
**Solution**: Database constraint prevents duplicates, return 409 Conflict
## References
- API Documentation: `/backend/src/features/stations/docs/API.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Feature README: `/backend/src/features/stations/README.md`
- Circuit Breaker Library: [opossum](https://nodeshift.dev/opossum/)

View File

@@ -0,0 +1,475 @@
# Google Maps API Setup Guide
## Overview
Complete guide for setting up Google Maps API for the Gas Stations feature. This includes API key creation, required API enablement, quota management, cost estimation, and security best practices.
## Prerequisites
- Google Cloud account
- Billing enabled on Google Cloud project
- Admin access to Google Cloud Console
## Step-by-Step Setup
### 1. Create Google Cloud Project
1. Navigate to [Google Cloud Console](https://console.cloud.google.com/)
2. Click **Select a project** > **New Project**
3. Enter project details:
- **Project Name**: "MotoVaultPro" (or your preferred name)
- **Organization**: Select your organization (if applicable)
- **Location**: Choose appropriate folder
4. Click **Create**
5. Wait for project creation (usually 10-30 seconds)
### 2. Enable Billing
Google Maps APIs require billing to be enabled, even for free tier usage.
1. Navigate to **Billing** in the left sidebar
2. Click **Link a billing account**
3. Select existing billing account or create new one
4. Follow prompts to add payment method
5. Confirm billing is linked to your project
**Note**: You receive $200 free credit per month. Most usage stays within free tier.
### 3. Enable Required APIs
The Gas Stations feature requires two Google Maps APIs:
#### Enable Places API
1. Navigate to **APIs & Services** > **Library**
2. Search for "Places API"
3. Click **Places API** (not "Places API (New)")
4. Click **Enable**
5. Wait for enablement (usually instant)
#### Enable Maps JavaScript API
1. Navigate to **APIs & Services** > **Library**
2. Search for "Maps JavaScript API"
3. Click **Maps JavaScript API**
4. Click **Enable**
5. Wait for enablement (usually instant)
**Why both APIs?**
- **Places API**: Backend searches for nearby gas stations
- **Maps JavaScript API**: Frontend displays interactive map
### 4. Create API Key
1. Navigate to **APIs & Services** > **Credentials**
2. Click **+ CREATE CREDENTIALS** > **API key**
3. API key is created (format: `AIzaSyD...`)
4. **Immediately restrict the key** (next step)
**DO NOT USE UNRESTRICTED KEY IN PRODUCTION**
### 5. Restrict API Key (Critical Security Step)
#### Application Restrictions
**For Backend Key** (used by Node.js server):
1. Click on the newly created API key
2. Under **Application restrictions**:
- Select **IP addresses**
- Add your server's IP addresses:
```
10.0.0.5/32 # Docker container IP
YOUR_SERVER_IP # Production server IP
```
3. Click **Save**
**For Frontend Key** (if using separate key):
1. Create a second API key following step 4
2. Under **Application restrictions**:
- Select **HTTP referrers (web sites)**
- Add allowed domains:
```
https://motovaultpro.com/*
http://localhost:3000/* # Development only
```
3. Click **Save**
#### API Restrictions
Limit the key to only required APIs:
1. Under **API restrictions**:
- Select **Restrict key**
- Check **Places API**
- Check **Maps JavaScript API** (if frontend key)
2. Click **Save**
**Important**: Frontend and backend can share one key, or use separate keys for finer control.
### 6. Copy API Key
1. Copy the API key value (starts with `AIzaSy...`)
2. Store securely (next section)
**Never commit API key to version control!**
## Storing the API Key
### Development (Local)
```bash
# Create secrets directory
mkdir -p ./secrets/app
# Add API key to secrets file
echo "AIzaSyYourActualKeyHere" > ./secrets/app/google-maps-api-key.txt
# Verify file was created
cat ./secrets/app/google-maps-api-key.txt
```
### Production (Docker Swarm)
```bash
# Create Docker secret
echo "AIzaSyYourActualKeyHere" | docker secret create google-maps-api-key -
# Verify secret created
docker secret ls
```
### Production (Kubernetes)
```bash
# Create Kubernetes secret
kubectl create secret generic google-maps-api-key \
--from-literal=api-key=AIzaSyYourActualKeyHere
# Verify secret created
kubectl get secrets
```
**Update deployment manifest**:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: motovaultpro-backend
spec:
template:
spec:
containers:
- name: backend
volumeMounts:
- name: google-maps-key
mountPath: /run/secrets/google-maps-api-key
subPath: google-maps-api-key
readOnly: true
volumes:
- name: google-maps-key
secret:
secretName: google-maps-api-key
items:
- key: api-key
path: google-maps-api-key
```
## Quota Management
### Free Tier Limits
Google provides $200 free credit per month, which translates to:
| API | Free Tier (per month) | Cost After Free Tier |
|-----|----------------------|---------------------|
| Places Nearby Search | 40,000 requests | $5 per 1,000 requests |
| Place Details | ~11,000 requests | $17 per 1,000 requests |
| Maps JavaScript API | 28,000 loads | $7 per 1,000 loads |
**Important**: Costs are approximate and subject to change. Check [Google Maps Platform Pricing](https://developers.google.com/maps/billing/gmp-billing) for current rates.
### Setting Quotas
Prevent unexpected costs by setting quota limits:
1. Navigate to **APIs & Services** > **Enabled APIs & services**
2. Click **Places API**
3. Click **Quotas** tab
4. Click **Edit Quotas** (pencil icon)
5. Set daily limits:
- **Queries per day**: 1000 (adjust based on usage)
- **Queries per 100 seconds per user**: 100
6. Click **Save**
Repeat for Maps JavaScript API if needed.
### Monitoring Usage
Track API usage to avoid surprises:
1. Navigate to **APIs & Services** > **Dashboard**
2. View usage graphs for each API
3. Set up alerts:
- Click **Quotas** tab
- Click **Create Alert** (bell icon)
- Configure alert thresholds (e.g., 80% of quota)
- Add email notification
## Cost Estimation
### Typical Usage Patterns
**Assumptions**:
- 100 active users per day
- Each user performs 3 searches per day
- Each search triggers 1 Places Nearby Search call
- 10% of searches result in saved stations (no additional cost)
**Calculation**:
```
Daily API calls: 100 users × 3 searches = 300 calls
Monthly API calls: 300 calls × 30 days = 9,000 calls
Cost:
- First 40,000 calls: FREE ($200 credit covers this)
- Total monthly cost: $0
```
**High Usage Scenario**:
- 1,000 active users per day
- 5 searches per user per day
- 150,000 calls per month
**Cost**:
```
Monthly API calls: 150,000 calls
Free tier: 40,000 calls
Billable calls: 110,000 calls
Cost:
- 110 batches × $5 = $550 per month
```
### Cost Optimization Strategies
1. **Cache Aggressively**: Store search results for 24 hours (already implemented)
2. **Limit Search Radius**: Default 5km instead of 50km reduces result size
3. **Paginate Results**: Only load details for visible stations
4. **Use Place IDs**: Cheaper than repeated searches
5. **Monitor Abuse**: Implement rate limiting per user
## Security Best Practices
### Key Restrictions
- Always restrict keys by IP (backend) or domain (frontend)
- Never use same key for development and production
- Rotate keys quarterly or after team member departures
- Use separate keys for different environments
### Environment Isolation
```bash
# Development
secrets/app/google-maps-api-key.txt → Development key (restricted to localhost)
# Production
Kubernetes secret → Production key (restricted to production IPs)
```
### Monitoring for Abuse
1. Set up billing alerts:
- Navigate to **Billing** > **Budgets & alerts**
- Click **Create Budget**
- Set budget amount (e.g., $50)
- Set alert thresholds (50%, 90%, 100%)
- Add email notifications
2. Review usage regularly:
- Check **APIs & Services** > **Dashboard** weekly
- Look for unusual spikes
- Investigate unexpected usage patterns
3. Implement application-level rate limiting:
```typescript
// Example: Limit user to 10 searches per hour
const rateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 requests per window
keyGenerator: (request) => request.user.sub // Per user
});
app.post('/api/stations/search', rateLimiter, searchHandler);
```
### Key Rotation
Rotate API keys periodically:
1. Create new API key (follow steps above)
2. Update secret in deployment:
```bash
# Update Docker secret
echo "NEW_KEY_HERE" > ./secrets/app/google-maps-api-key.txt
docker compose restart mvp-backend mvp-frontend
# Update Kubernetes secret
kubectl delete secret google-maps-api-key
kubectl create secret generic google-maps-api-key \
--from-literal=api-key=NEW_KEY_HERE
kubectl rollout restart deployment/motovaultpro-backend
```
3. Verify new key works
4. Delete old key from Google Cloud Console
## Troubleshooting
### "API Key Invalid" Error
**Symptom**: 400 error with message "The provided API key is invalid"
**Solutions**:
1. Verify key is correctly copied (no extra spaces/newlines)
2. Check secret file exists:
```bash
docker compose exec mvp-backend cat /run/secrets/google-maps-api-key
```
3. Verify APIs are enabled in Google Cloud Console
4. Wait 5 minutes after creating key (propagation delay)
### "API Key Not Authorized" Error
**Symptom**: 403 error with message "This API project is not authorized"
**Solutions**:
1. Verify IP restriction includes server IP
2. Check API restrictions allow Places API
3. Verify billing is enabled
4. Remove restrictions temporarily to test (then re-add)
### "Quota Exceeded" Error
**Symptom**: 429 error with message "You have exceeded your quota"
**Solutions**:
1. Check usage in Google Cloud Console
2. Increase quota limit (if within budget)
3. Review caching strategy (24-hour TTL implemented)
4. Implement rate limiting per user
### "Daily Limit Exceeded" Error
**Symptom**: Requests fail after certain number of calls
**Solutions**:
1. Check quota settings in Google Cloud Console
2. Increase daily limit
3. Review application for excessive API calls
4. Implement exponential backoff on failures
### Key Not Loading in Container
**Symptom**: Container logs show "Google Maps API key not found"
**Solutions**:
1. Verify secret file exists:
```bash
ls -la ./secrets/app/
```
2. Check Docker volume mount in docker-compose.yml:
```yaml
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
```
3. Verify file permissions (must be readable):
```bash
chmod 644 ./secrets/app/google-maps-api-key.txt
```
4. Restart containers:
```bash
docker compose restart mvp-backend mvp-frontend
```
### High Unexpected Costs
**Symptom**: Billing alert for unexpected API usage
**Solutions**:
1. Review API usage in Google Cloud Console
2. Check for infinite loops in code
3. Verify caching is working (check Redis/PostgreSQL)
4. Look for bot traffic or abuse
5. Implement stricter rate limiting
6. Reduce search radius or result count
## Verification Checklist
After setup, verify everything works:
- [ ] Google Cloud project created
- [ ] Billing enabled
- [ ] Places API enabled
- [ ] Maps JavaScript API enabled (if using frontend map)
- [ ] API key created
- [ ] Key restricted by IP/domain
- [ ] Key restricted to required APIs
- [ ] Key stored in secrets file
- [ ] Quota limits configured
- [ ] Billing alerts configured
- [ ] Key tested in development:
```bash
curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}'
```
- [ ] No errors in container logs
- [ ] Search returns results
- [ ] Usage appears in Google Cloud Console
## Cost Monitoring Dashboard
Create a cost monitoring routine:
**Weekly Review**:
1. Check API usage dashboard
2. Verify costs are within budget
3. Review for unusual patterns
**Monthly Review**:
1. Analyze usage trends
2. Optimize expensive queries
3. Adjust quotas if needed
4. Review and adjust budget alerts
**Quarterly Review**:
1. Rotate API keys
2. Review user growth vs. API costs
3. Evaluate alternative pricing tiers
4. Update cost projections
## Additional Resources
- [Google Maps Platform Documentation](https://developers.google.com/maps/documentation)
- [Places API Documentation](https://developers.google.com/maps/documentation/places/web-service/overview)
- [Pricing Calculator](https://cloud.google.com/maps-platform/pricing)
- [Best Practices Guide](https://developers.google.com/maps/documentation/places/web-service/best-practices)
- [API Key Best Practices](https://developers.google.com/maps/api-security-best-practices)
## Support
For issues with:
- **Google Cloud billing**: Contact Google Cloud Support
- **API setup**: Review this guide and Google documentation
- **MotoVaultPro integration**: Check container logs and application documentation
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- API Documentation: `/backend/src/features/stations/docs/API.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Feature README: `/backend/src/features/stations/README.md`
- Runtime Config: `/frontend/docs/RUNTIME-CONFIG.md`

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`

View File

@@ -0,0 +1,283 @@
# Gas Stations Feature - Database Migration Guide
## Overview
Complete guide for running and verifying database migrations for the Gas Stations feature. Migrations create the necessary tables, indexes, and constraints for station caching and user favorites.
## Migration Files
Location: `/backend/src/features/stations/migrations/`
### 001_create_stations_tables.sql
Creates two tables:
- `station_cache`: Temporary storage for Google Places API results
- `saved_stations`: User's favorite stations with metadata
### 002_add_indexes.sql
Adds performance indexes:
- `idx_station_cache_place_id`: Fast lookups by Google Place ID
- `idx_station_cache_created_at`: Efficient cache cleanup queries
- `idx_saved_stations_user_id`: Fast user-scoped queries
- `idx_saved_stations_place_id`: Fast station lookups
- `idx_saved_stations_deleted_at`: Efficient soft delete filtering
## Running Migrations
### Prerequisites
- PostgreSQL database accessible
- Database connection configured
- Backend container running
### Run All Migrations
```bash
# In Docker environment
docker compose exec mvp-backend npm run migrate
# Or from backend directory
cd backend
npm run migrate
```
### Run Specific Migration
```bash
# Run only stations migrations
docker compose exec mvp-backend \
psql $DATABASE_URL -f /app/src/features/stations/migrations/001_create_stations_tables.sql
docker compose exec mvp-backend \
psql $DATABASE_URL -f /app/src/features/stations/migrations/002_add_indexes.sql
```
## Verification Steps
### 1. Verify Tables Created
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "\dt station*"
```
**Expected Output**:
```
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+----------
public | station_cache | table | postgres
public | saved_stations | table | postgres
(2 rows)
```
### 2. Verify Table Structure
**station_cache**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "\d station_cache"
```
**Expected Output**:
```
Table "public.station_cache"
Column | Type | Collation | Nullable | Default
-------------+-----------------------------+-----------+----------+---------
id | uuid | | not null | uuid_generate_v4()
place_id | character varying(255) | | not null |
name | character varying(255) | | not null |
address | character varying(500) | | |
latitude | numeric(10,8) | | not null |
longitude | numeric(11,8) | | not null |
rating | numeric(2,1) | | |
photo_url | text | | |
created_at | timestamp without time zone | | | CURRENT_TIMESTAMP
Indexes:
"station_cache_pkey" PRIMARY KEY, btree (id)
"station_cache_place_id_key" UNIQUE CONSTRAINT, btree (place_id)
"idx_station_cache_created_at" btree (created_at)
"idx_station_cache_place_id" btree (place_id)
```
**saved_stations**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "\d saved_stations"
```
**Expected Output**:
```
Table "public.saved_stations"
Column | Type | Collation | Nullable | Default
--------------+-----------------------------+-----------+----------+---------
id | uuid | | not null | uuid_generate_v4()
user_id | character varying(255) | | not null |
place_id | character varying(255) | | not null |
nickname | character varying(255) | | |
notes | text | | |
is_favorite | boolean | | | false
created_at | timestamp without time zone | | | CURRENT_TIMESTAMP
updated_at | timestamp without time zone | | | CURRENT_TIMESTAMP
deleted_at | timestamp without time zone | | |
Indexes:
"saved_stations_pkey" PRIMARY KEY, btree (id)
"saved_stations_user_id_place_id_key" UNIQUE CONSTRAINT, btree (user_id, place_id)
"idx_saved_stations_deleted_at" btree (deleted_at)
"idx_saved_stations_place_id" btree (place_id)
"idx_saved_stations_user_id" btree (user_id)
```
### 3. Verify Indexes Created
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "
SELECT
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename LIKE 'station%'
ORDER BY tablename, indexname;
"
```
**Expected Output**: Lists all indexes for station_cache and saved_stations tables.
### 4. Test Insert Operations
**Insert into station_cache**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
INSERT INTO station_cache (place_id, name, address, latitude, longitude, rating)
VALUES (
'test-place-id-123',
'Test Shell Station',
'123 Test St, San Francisco, CA',
37.7749,
-122.4194,
4.2
);
SELECT * FROM station_cache WHERE place_id = 'test-place-id-123';
EOF
```
**Insert into saved_stations**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
INSERT INTO saved_stations (user_id, place_id, nickname, is_favorite)
VALUES (
'test-user-123',
'test-place-id-123',
'My Test Station',
true
);
SELECT * FROM saved_stations WHERE user_id = 'test-user-123';
EOF
```
### 5. Test Constraints
**Test UNIQUE constraint on saved_stations**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
-- First insert should succeed
INSERT INTO saved_stations (user_id, place_id) VALUES ('user-1', 'place-1');
-- Second insert with same user_id + place_id should fail
INSERT INTO saved_stations (user_id, place_id) VALUES ('user-1', 'place-1');
EOF
```
**Expected**: Second insert fails with "duplicate key value violates unique constraint"
### 6. Clean Up Test Data
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
DELETE FROM saved_stations WHERE user_id LIKE 'test-%';
DELETE FROM station_cache WHERE place_id LIKE 'test-%';
EOF
```
## Rollback Procedure
If migrations need to be reverted:
### Manual Rollback
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
-- Drop tables (cascades to all constraints and indexes)
DROP TABLE IF EXISTS saved_stations CASCADE;
DROP TABLE IF EXISTS station_cache CASCADE;
EOF
```
### Verify Rollback
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "\dt station*"
```
**Expected**: "Did not find any relations"
## Migration Version Tracking
MotoVaultPro does not currently use a migration tracking table. Migrations are applied manually and tracked via git commit history.
**Future Enhancement**: Consider adding migration tracking table:
```sql
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## Troubleshooting
### Migration Fails with "uuid_generate_v4 does not exist"
**Cause**: PostgreSQL extension not enabled
**Solution**:
```bash
docker compose exec postgres psql -U postgres -d motovaultpro -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
```
### Migration Fails with "permission denied"
**Cause**: Database user lacks CREATE TABLE permission
**Solution**:
```bash
docker compose exec postgres psql -U postgres <<EOF
GRANT CREATE ON SCHEMA public TO your_db_user;
EOF
```
### Table Already Exists
**Cause**: Migrations run multiple times
**Solution**: Drop and recreate tables (see Rollback Procedure)
### Index Creation Slow
**Cause**: Large dataset or concurrent operations
**Solution**: Create indexes with `CONCURRENTLY` option:
```sql
CREATE INDEX CONCURRENTLY idx_name ON table_name (column_name);
```
## Performance Notes
- Indexes are created AFTER table creation for optimal performance
- station_cache uses auto-cleanup (scheduled job), not manual deletion
- saved_stations uses soft deletes (deleted_at) for referential integrity
- All indexes are B-tree for standard query performance
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- Database Schema: `/backend/src/features/stations/README.md#database-schema`
- Deployment Checklist: `/backend/src/features/stations/docs/deployment/DEPLOYMENT-CHECKLIST.md`

View File

@@ -0,0 +1,698 @@
# Gas Stations Feature - Deployment Checklist
## Overview
Complete deployment checklist for the Gas Stations feature. This document covers configuration, secrets, database migrations, and validation steps required for production deployment.
## Pre-Deployment Checklist
### 1. Google Maps API Configuration
- [ ] Google Cloud project created
- [ ] Billing enabled on Google Cloud project
- [ ] Places API enabled
- [ ] Maps JavaScript API enabled (for frontend)
- [ ] API key created
- [ ] API key restricted by IP (backend)
- [ ] API key restricted by domain (frontend, if separate key)
- [ ] API key restricted to required APIs only
- [ ] Quota limits configured (prevent unexpected costs)
- [ ] Billing alerts configured ($50, $100 thresholds)
- [ ] API key tested in development environment
**Verification**:
```bash
# Test API key works
curl "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=5000&type=gas_station&key=YOUR_API_KEY"
```
**Documentation**: See `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
### 2. Secrets Configuration
#### Backend Secret
- [ ] Secret file created: `./secrets/app/google-maps-api-key.txt`
- [ ] Secret contains valid API key (no extra whitespace/newlines)
- [ ] File permissions set correctly (`chmod 644`)
- [ ] Docker volume mount configured in `docker-compose.yml`:
```yaml
mvp-backend:
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
```
- [ ] Environment variable set: `SECRETS_DIR=/run/secrets`
**Verification**:
```bash
# Check secret file exists locally
cat ./secrets/app/google-maps-api-key.txt
# Check secret mounted in backend container
docker compose exec mvp-backend cat /run/secrets/google-maps-api-key
```
#### Frontend Secret
- [ ] Same secret file used (or separate key if using domain restrictions)
- [ ] Docker volume mount configured in `docker-compose.yml`:
```yaml
mvp-frontend:
volumes:
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
```
- [ ] Environment variable set: `SECRETS_DIR=/run/secrets`
- [ ] Entrypoint script configured to generate config.js
**Verification**:
```bash
# Check config.js generated
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Should output: window.CONFIG = { googleMapsApiKey: "AIza..." }
# Check in browser console
# Open https://motovaultpro.com
# Run: console.log(window.CONFIG)
```
### 3. Database Migrations
- [ ] PostgreSQL database accessible
- [ ] Database connection string configured
- [ ] All migrations files present in `backend/src/features/stations/migrations/`
- [ ] Migrations not run yet (will run during deployment)
**Migration Files**:
1. `001_create_stations_tables.sql` - Creates station_cache and saved_stations tables
2. `002_add_indexes.sql` - Adds performance indexes
**Verification** (before running migrations):
```bash
# Check tables don't exist yet
docker compose exec postgres psql -U postgres -d motovaultpro -c "\dt station*"
# Should return "Did not find any relations"
```
### 4. Redis Cache
- [ ] Redis service running
- [ ] Backend can connect to Redis
- [ ] Connection string configured: `redis://redis:6379`
**Verification**:
```bash
# Check Redis is running
docker compose exec redis redis-cli ping
# Should return: PONG
# Check backend can connect
docker compose logs mvp-backend | grep -i redis
# Should show successful connection
```
### 5. Code Quality
- [ ] All linters passing: `npm run lint`
- [ ] All type checks passing: `npm run type-check`
- [ ] All tests passing: `npm test -- features/stations`
- [ ] No console.log statements in production code
- [ ] No commented-out code
- [ ] No TODO comments for critical functionality
**Verification**:
```bash
cd backend
npm run lint
npm run type-check
npm test -- features/stations
cd ../frontend
npm run lint
npm run type-check
npm test -- stations
```
### 6. Dependencies
- [ ] All npm packages installed
- [ ] No security vulnerabilities: `npm audit`
- [ ] Package versions match package-lock.json
- [ ] Docker images built successfully
**Verification**:
```bash
cd backend
npm audit --production
npm ci # Clean install
cd ../frontend
npm audit --production
npm ci
# Build containers
make rebuild
```
## Deployment Steps
### Step 1: Backup Existing System
```bash
# Backup database
docker compose exec postgres pg_dump -U postgres motovaultpro > backup_$(date +%Y%m%d_%H%M%S).sql
# Backup secrets (if rotating)
cp ./secrets/app/google-maps-api-key.txt ./secrets/app/google-maps-api-key.txt.bak
# Backup docker volumes (if any)
docker compose down
tar -czf volumes_backup_$(date +%Y%m%d_%H%M%S).tar.gz /var/lib/docker/volumes/
```
### Step 2: Stop Services
```bash
# Graceful shutdown
docker compose down
# Verify all containers stopped
docker compose ps
```
### Step 3: Update Code
```bash
# Pull latest code
git fetch origin
git checkout main
git pull origin main
# Verify correct branch and commit
git log -1
git status
```
### Step 4: Install Dependencies
```bash
# Backend dependencies
cd backend
npm ci
cd ..
# Frontend dependencies
cd frontend
npm ci
cd ..
```
### Step 5: Run Database Migrations
```bash
# Start database (if not already running)
docker compose up -d postgres
# Wait for database to be ready
sleep 5
# Run migrations
docker compose exec mvp-backend npm run migrate
# Verify migrations ran successfully
docker compose exec postgres psql -U postgres -d motovaultpro -c "\dt station*"
# Should show: station_cache, saved_stations
```
**Expected Output**:
```
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+----------
public | station_cache | table | postgres
public | saved_stations | table | postgres
```
### Step 6: Configure Secrets
```bash
# Ensure secrets directory exists
mkdir -p ./secrets/app
# Add Google Maps API key (if not already present)
echo "YOUR_GOOGLE_MAPS_API_KEY" > ./secrets/app/google-maps-api-key.txt
# Set correct permissions
chmod 644 ./secrets/app/google-maps-api-key.txt
# Verify secret file
cat ./secrets/app/google-maps-api-key.txt
```
### Step 7: Build and Start Services
```bash
# Build images
make rebuild
# Start services
docker compose up -d
# Monitor startup logs
docker compose logs -f
```
**Watch for**:
- Backend: "Server listening on port 3001"
- Frontend: "[Config] Generated /usr/share/nginx/html/config.js"
- Database: "database system is ready to accept connections"
- Redis: "Ready to accept connections"
### Step 8: Verify Services Started
```bash
# Check all containers running
docker compose ps
# All should show "Up" status
# Check backend health
curl -s http://localhost:3001/health | jq
# Should return: {"status":"ok","features":["stations",...]}
# Check frontend loads
curl -s https://motovaultpro.com | grep "MotoVaultPro"
# Should return HTML with app name
```
## Post-Deployment Validation
### 1. Health Checks
Run comprehensive health checks to verify deployment:
**Backend Health**:
```bash
# Overall health check
curl http://localhost:3001/health
# Stations feature health (implicit in API availability)
curl -H "Authorization: Bearer $TEST_TOKEN" \
http://localhost:3001/api/stations/saved
```
**Frontend Health**:
```bash
# Check frontend loads
curl -I https://motovaultpro.com
# Should return: 200 OK
# Check config.js loads
curl https://motovaultpro.com/config.js
# Should return: window.CONFIG = {...}
```
**Database Health**:
```bash
# Check tables exist
docker compose exec postgres psql -U postgres -d motovaultpro \
-c "SELECT COUNT(*) FROM station_cache;"
docker compose exec postgres psql -U postgres -d motovaultpro \
-c "SELECT COUNT(*) FROM saved_stations;"
```
**Redis Health**:
```bash
# Check Redis connection
docker compose exec redis redis-cli ping
# Should return: PONG
# Check keys (should be empty initially)
docker compose exec redis redis-cli KEYS "station:*"
```
### 2. API Endpoint Testing
Test all stations API endpoints with actual JWT:
**Get JWT Token**:
```bash
# Authenticate via Auth0 to get token
# Or use test token from Auth0 dashboard
export TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Test Search Endpoint**:
```bash
curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000
}' | jq
```
**Expected Response**:
```json
{
"stations": [
{
"placeId": "ChIJ...",
"name": "Shell Gas Station",
"address": "123 Main St, San Francisco, CA",
"latitude": 37.7750,
"longitude": -122.4195,
"rating": 4.2,
"distance": 150
}
],
"searchLocation": {
"latitude": 37.7749,
"longitude": -122.4194
},
"searchRadius": 5000,
"timestamp": "2025-01-15T10:30:00.000Z"
}
```
**Test Save Endpoint**:
```bash
# First search to populate cache
PLACE_ID=$(curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}' | \
jq -r '.stations[0].placeId')
# Then save station
curl -X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"placeId\": \"$PLACE_ID\",
\"nickname\": \"Test Station\",
\"isFavorite\": true
}" | jq
```
**Test Get Saved Endpoint**:
```bash
curl -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $TOKEN" | jq
```
**Test Delete Endpoint**:
```bash
curl -X DELETE http://localhost:3001/api/stations/saved/$PLACE_ID \
-H "Authorization: Bearer $TOKEN"
# Should return 204 No Content
```
### 3. Frontend Testing
**Browser Testing**:
1. Open https://motovaultpro.com in browser
2. Navigate to Stations feature
3. Click "Use Current Location" (grant permission)
4. Verify search completes and stations display
5. Click on map marker, verify details shown
6. Save a station, verify it appears in Saved tab
7. Test on mobile device (responsive layout)
**Console Checks**:
```javascript
// Check runtime config loaded
console.log(window.CONFIG);
// Should show: { googleMapsApiKey: "AIza..." }
// Check no errors in console
// Should be clean, no red errors
```
### 4. User Data Isolation Verification
Verify users can only access their own data:
```bash
# User 1 saves a station
USER1_TOKEN="..."
PLACE_ID=$(curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $USER1_TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}' | \
jq -r '.stations[0].placeId')
curl -X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $USER1_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"placeId\": \"$PLACE_ID\"}"
# User 2 should NOT see User 1's saved station
USER2_TOKEN="..."
curl -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $USER2_TOKEN" | jq
# Should return: [] (empty array)
```
### 5. Performance Validation
Test response times meet requirements:
```bash
# Search endpoint (should be < 1500ms)
time curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}'
# Saved stations endpoint (should be < 100ms)
time curl -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $TOKEN"
```
**Expected Response Times**:
- Search: 500-1500ms (includes Google API call)
- Save: 50-100ms
- Get Saved: 50-100ms
- Delete: 50-100ms
### 6. Monitoring Setup
**Container Logs**:
```bash
# Monitor all logs
docker compose logs -f
# Monitor backend only
docker compose logs -f mvp-backend
# Search for errors
docker compose logs | grep -i error
docker compose logs | grep -i warning
```
**Google Maps API Usage**:
1. Go to Google Cloud Console
2. Navigate to APIs & Services > Dashboard
3. View Places API usage
4. Verify requests are being logged
**Database Monitoring**:
```bash
# Check station cache size
docker compose exec postgres psql -U postgres -d motovaultpro \
-c "SELECT COUNT(*) as cache_count FROM station_cache;"
# Check saved stations count
docker compose exec postgres psql -U postgres -d motovaultpro \
-c "SELECT COUNT(*) as saved_count FROM saved_stations;"
```
## Rollback Procedure
If issues arise during deployment, follow this rollback procedure:
### Immediate Rollback
```bash
# Stop containers
docker compose down
# Restore previous code
git checkout <PREVIOUS_COMMIT_HASH>
# Restore secrets backup (if changed)
cp ./secrets/app/google-maps-api-key.txt.bak ./secrets/app/google-maps-api-key.txt
# Rollback database migrations
docker compose exec postgres psql -U postgres -d motovaultpro < backup_YYYYMMDD_HHMMSS.sql
# Restart services
docker compose up -d
# Verify rollback successful
curl http://localhost:3001/health
```
### Partial Rollback (Disable Feature)
If only stations feature needs to be disabled:
```bash
# Remove stations routes from backend
# Edit backend/src/app.ts to comment out stations routes
# Rebuild backend
docker compose up -d --build mvp-backend
# Verify other features still work
curl http://localhost:3001/health
```
### Database Rollback Only
If only database changes need to be reverted:
```bash
# Drop stations tables
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
DROP TABLE IF EXISTS saved_stations;
DROP TABLE IF EXISTS station_cache;
EOF
# Verify tables removed
docker compose exec postgres psql -U postgres -d motovaultpro -c "\dt station*"
# Should return: Did not find any relations
```
## Troubleshooting
### Common Issues
**Issue**: Backend can't read Google Maps API key
**Symptoms**:
- Logs show "Google Maps API key not found"
- Search endpoint returns 502 error
**Solutions**:
```bash
# Check secret file exists
docker compose exec mvp-backend cat /run/secrets/google-maps-api-key
# Check mount in docker-compose.yml
grep -A5 "mvp-backend:" docker-compose.yml | grep "google-maps"
# Restart backend
docker compose restart mvp-backend
```
**Issue**: Frontend config.js not generated
**Symptoms**:
- Browser console shows "window.CONFIG is undefined"
- Map doesn't load
**Solutions**:
```bash
# Check entrypoint script runs
docker compose logs mvp-frontend | grep -i config
# Check config.js exists
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Restart frontend
docker compose restart mvp-frontend
```
**Issue**: Database migrations fail
**Symptoms**:
- Migrations error during deployment
- Tables don't exist
**Solutions**:
```bash
# Check database connection
docker compose exec postgres psql -U postgres -l
# Run migrations manually
docker compose exec mvp-backend npm run migrate
# Check migration logs
docker compose logs mvp-backend | grep -i migration
```
**Issue**: Search returns no results
**Symptoms**:
- Search completes but returns empty array
- No errors in logs
**Solutions**:
1. Verify location is correct (valid lat/lng)
2. Try larger radius (50km instead of 5km)
3. Check Google Maps API quota not exceeded
4. Verify circuit breaker not open
**Issue**: High Google Maps API costs
**Symptoms**:
- Billing alert triggered
- Unexpected charges
**Solutions**:
1. Check usage in Google Cloud Console
2. Review quota limits
3. Implement rate limiting (if not already)
4. Reduce search radius
5. Increase cache TTL (currently 24h)
## Production Monitoring
### Daily Checks
- [ ] Check container health: `docker compose ps`
- [ ] Review error logs: `docker compose logs | grep -i error`
- [ ] Verify API responding: `curl http://localhost:3001/health`
### Weekly Checks
- [ ] Review Google Maps API usage
- [ ] Check database size: `SELECT pg_size_pretty(pg_database_size('motovaultpro'));`
- [ ] Review saved stations count
- [ ] Check for any user-reported issues
### Monthly Checks
- [ ] Review Google Maps API costs
- [ ] Rotate API keys (if policy requires)
- [ ] Update dependencies: `npm audit`
- [ ] Review and archive old logs
## Support Contacts
**Google Maps API Issues**:
- Google Cloud Support: https://cloud.google.com/support
**Infrastructure Issues**:
- Review container logs: `docker compose logs`
- Check database connectivity
- Verify Redis connectivity
**Application Issues**:
- Review backend logs
- Check frontend browser console
- Test API endpoints with curl
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- API Documentation: `/backend/src/features/stations/docs/API.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Database Migrations: `/backend/src/features/stations/docs/deployment/DATABASE-MIGRATIONS.md`
- Secrets Verification: `/backend/src/features/stations/docs/deployment/SECRETS-VERIFICATION.md`
- Health Checks: `/backend/src/features/stations/docs/deployment/HEALTH-CHECKS.md`
- Production Readiness: `/backend/src/features/stations/docs/deployment/PRODUCTION-READINESS.md`

View File

@@ -0,0 +1,537 @@
# Gas Stations Feature - Health Checks Validation
## Overview
Comprehensive health check procedures for validating the Gas Stations feature in production. This document covers API endpoint testing, database validation, external service integration, and performance benchmarks.
## Quick Health Check
Run this single command for immediate status:
```bash
# All-in-one health check
curl -s http://localhost:3001/health | jq
```
**Expected Output**:
```json
{
"status": "ok",
"timestamp": "2025-01-15T10:30:00.000Z",
"services": {
"database": "connected",
"redis": "connected"
}
}
```
## Detailed Health Checks
### 1. API Endpoints Health
#### Get JWT Token First
```bash
# From Auth0 authentication flow or test token
export TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
```
#### Test Search Endpoint
```bash
curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000
}' \
-w "\nHTTP Status: %{http_code}\nTime: %{time_total}s\n" \
| jq
```
**Expected**:
- HTTP Status: 200
- Response contains `stations` array
- Response contains `searchLocation` object
- Time: <1.5 seconds
#### Test Save Endpoint
```bash
# First search to get a place ID
PLACE_ID=$(curl -s -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}' \
| jq -r '.stations[0].placeId')
# Then save station
curl -X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"placeId\": \"$PLACE_ID\", \"nickname\": \"Health Check Station\"}" \
-w "\nHTTP Status: %{http_code}\nTime: %{time_total}s\n" \
| jq
```
**Expected**:
- HTTP Status: 201
- Response contains saved station with ID
- Time: <0.1 seconds
#### Test Get Saved Endpoint
```bash
curl -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $TOKEN" \
-w "\nHTTP Status: %{http_code}\nTime: %{time_total}s\n" \
| jq
```
**Expected**:
- HTTP Status: 200
- Response is array
- Time: <0.1 seconds
#### Test Delete Endpoint
```bash
curl -X DELETE http://localhost:3001/api/stations/saved/$PLACE_ID \
-H "Authorization: Bearer $TOKEN" \
-w "\nHTTP Status: %{http_code}\nTime: %{time_total}s\n"
```
**Expected**:
- HTTP Status: 204 (No Content)
- Time: <0.1 seconds
### 2. Database Health
#### Check Tables Exist
```bash
docker compose exec postgres psql -U postgres -d motovaultpro \
-c "SELECT tablename FROM pg_tables WHERE tablename LIKE 'station%' ORDER BY tablename;"
```
**Expected Output**:
```
tablename
-----------------
station_cache
saved_stations
(2 rows)
```
#### Check Table Sizes
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
n_live_tup AS row_count
FROM pg_stat_user_tables
WHERE tablename LIKE 'station%'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
EOF
```
**Expected**: Tables exist with reasonable sizes (not unexpectedly large)
#### Check Indexes
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
SELECT
tablename,
indexname,
idx_scan as index_scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched
FROM pg_stat_user_indexes
WHERE tablename LIKE 'station%'
ORDER BY tablename, indexname;
EOF
```
**Expected**: All indexes present and being used (idx_scan > 0 after some usage)
#### Check Data Integrity
```bash
docker compose exec postgres psql -U postgres -d motovaultpro <<EOF
-- Check for orphaned saved stations (station not in cache)
SELECT COUNT(*) as orphaned_count
FROM saved_stations ss
LEFT JOIN station_cache sc ON ss.place_id = sc.place_id
WHERE sc.place_id IS NULL
AND ss.deleted_at IS NULL;
-- Check for duplicate saved stations (should be 0)
SELECT user_id, place_id, COUNT(*) as duplicates
FROM saved_stations
WHERE deleted_at IS NULL
GROUP BY user_id, place_id
HAVING COUNT(*) > 1;
EOF
```
**Expected**:
- Orphaned count: Low (acceptable if stations aged out of cache)
- Duplicates: 0 (enforced by UNIQUE constraint)
### 3. Redis Cache Health
#### Check Redis Connection
```bash
docker compose exec redis redis-cli ping
```
**Expected**: `PONG`
#### Check Redis Memory
```bash
docker compose exec redis redis-cli info memory | grep used_memory_human
```
**Expected**: Reasonable memory usage (depends on cache size)
#### Check Station Cache Keys
```bash
docker compose exec redis redis-cli KEYS "station:*"
```
**Expected**: List of cached station keys (if any recent searches)
#### Check Cache TTL
```bash
# Get a station cache key
KEY=$(docker compose exec redis redis-cli KEYS "station:*" | head -1)
# Check TTL
docker compose exec redis redis-cli TTL $KEY
```
**Expected**: TTL value (3600 = 1 hour remaining)
### 4. Google Maps API Health
#### Test Direct API Call
```bash
API_KEY=$(cat ./secrets/app/google-maps-api-key.txt)
curl -s "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=5000&type=gas_station&key=$API_KEY" \
| jq '.status, (.results | length)'
```
**Expected Output**:
```
"OK"
20
```
#### Check API Quota
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Navigate to APIs & Services > Dashboard
3. Click on Places API
4. Check current usage
**Expected**: Usage within quota limits
#### Check Circuit Breaker State
```bash
docker compose logs mvp-backend | grep -i "circuit breaker"
```
**Expected**: No "circuit breaker opened" messages (or rare occurrences)
### 5. Frontend Health
#### Check Frontend Loads
```bash
curl -I https://motovaultpro.com
```
**Expected**:
```
HTTP/2 200
content-type: text/html
```
#### Check config.js Loads
```bash
curl -s https://motovaultpro.com/config.js
```
**Expected Output**:
```javascript
window.CONFIG = {
googleMapsApiKey: "AIzaSy..."
};
```
#### Check Browser Console (Manual)
1. Open https://motovaultpro.com
2. Open Developer Tools (F12)
3. Go to Console tab
4. Check for errors
**Expected**: No errors related to:
- Config loading
- Google Maps API
- API calls
#### Check Network Requests (Manual)
1. Open Developer Tools (F12)
2. Go to Network tab
3. Navigate to Stations feature
4. Perform a search
**Expected**:
- config.js: 200 OK
- Search API call: 200 OK
- Google Maps script: 200 OK
### 6. User Isolation Validation
Verify users can only access their own data:
```bash
# User 1 Token
USER1_TOKEN="..."
# User 1 saves a station
PLACE_ID=$(curl -s -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $USER1_TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}' \
| jq -r '.stations[0].placeId')
curl -s -X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $USER1_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"placeId\": \"$PLACE_ID\"}" > /dev/null
# User 2 Token
USER2_TOKEN="..."
# User 2 tries to access User 1's saved stations
curl -s -X GET http://localhost:3001/api/stations/saved \
-H "Authorization: Bearer $USER2_TOKEN" \
| jq '. | length'
```
**Expected**: 0 (User 2 cannot see User 1's stations)
### 7. Performance Benchmarks
#### Search Performance
```bash
# Run 10 searches and measure average time
for i in {1..10}; do
curl -s -o /dev/null -w "%{time_total}\n" \
-X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}'
done | awk '{sum+=$1} END {print "Average:", sum/NR, "seconds"}'
```
**Expected**: Average < 1.5 seconds
#### Save Performance
```bash
# Measure save operation time
time curl -s -o /dev/null \
-X POST http://localhost:3001/api/stations/save \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"placeId\": \"$PLACE_ID\"}"
```
**Expected**: < 0.1 seconds
#### Load Test (Optional)
Use Apache Bench or similar:
```bash
# Install Apache Bench
sudo apt-get install apache2-utils
# Run load test (100 requests, 10 concurrent)
ab -n 100 -c 10 -T 'application/json' -H "Authorization: Bearer $TOKEN" \
-p search_payload.json \
http://localhost:3001/api/stations/search
```
**Expected**:
- 99% of requests < 2 seconds
- No failed requests
## Automated Health Check Script
Create a comprehensive health check script:
```bash
#!/bin/bash
# health-check.sh
set -e
echo "=== Gas Stations Feature Health Check ==="
echo ""
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
TOKEN="${JWT_TOKEN:-$1}"
if [ -z "$TOKEN" ]; then
echo "${RED}ERROR: JWT token required${NC}"
echo "Usage: ./health-check.sh <jwt_token>"
echo "Or set JWT_TOKEN environment variable"
exit 1
fi
echo "1. Backend Health Check..."
if curl -s http://localhost:3001/health | jq -e '.status == "ok"' > /dev/null; then
echo "${GREEN}✓ Backend healthy${NC}"
else
echo "${RED}✗ Backend unhealthy${NC}"
exit 1
fi
echo ""
echo "2. Database Tables Check..."
if docker compose exec -T postgres psql -U postgres -d motovaultpro \
-tc "SELECT COUNT(*) FROM pg_tables WHERE tablename IN ('station_cache', 'saved_stations');" \
| grep -q "2"; then
echo "${GREEN}✓ Tables exist${NC}"
else
echo "${RED}✗ Tables missing${NC}"
exit 1
fi
echo ""
echo "3. Redis Connection Check..."
if docker compose exec -T redis redis-cli ping | grep -q "PONG"; then
echo "${GREEN}✓ Redis connected${NC}"
else
echo "${RED}✗ Redis unavailable${NC}"
exit 1
fi
echo ""
echo "4. Search API Check..."
if curl -s -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude": 37.7749, "longitude": -122.4194}' \
| jq -e '.stations | length > 0' > /dev/null; then
echo "${GREEN}✓ Search API working${NC}"
else
echo "${RED}✗ Search API failed${NC}"
exit 1
fi
echo ""
echo "5. Frontend Config Check..."
if docker compose exec -T mvp-frontend cat /usr/share/nginx/html/config.js \
| grep -q "googleMapsApiKey"; then
echo "${GREEN}✓ Frontend config generated${NC}"
else
echo "${RED}✗ Frontend config missing${NC}"
exit 1
fi
echo ""
echo "6. Google Maps API Check..."
API_KEY=$(cat ./secrets/app/google-maps-api-key.txt)
if curl -s "https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=5000&type=gas_station&key=$API_KEY" \
| jq -e '.status == "OK"' > /dev/null; then
echo "${GREEN}✓ Google Maps API responding${NC}"
else
echo "${YELLOW}⚠ Google Maps API issue (check quota/key)${NC}"
fi
echo ""
echo "${GREEN}=== All Health Checks Passed ===${NC}"
```
**Usage**:
```bash
chmod +x health-check.sh
JWT_TOKEN="your_token_here" ./health-check.sh
```
## Monitoring Checklist
### Daily Monitoring
- [ ] Backend container running: `docker compose ps mvp-backend`
- [ ] Frontend container running: `docker compose ps mvp-frontend`
- [ ] No errors in logs: `docker compose logs --tail=100 | grep -i error`
- [ ] Health endpoint responding: `curl http://localhost:3001/health`
### Weekly Monitoring
- [ ] Google Maps API usage within limits
- [ ] Database size reasonable: Check table sizes
- [ ] Redis memory usage acceptable
- [ ] No circuit breaker failures
- [ ] Performance metrics stable
### Monthly Monitoring
- [ ] Review Google Maps API costs
- [ ] Rotate API keys (if policy requires)
- [ ] Clean up old test data
- [ ] Review and archive logs
## Alerting
### Critical Alerts
Set up alerts for:
- Backend container down
- Health check fails
- Google Maps API quota exceeded
- Database connection failures
- Circuit breaker opens frequently (>10 times/hour)
### Warning Alerts
Set up warnings for:
- Response times > 2 seconds
- Google Maps API usage > 80% of quota
- Redis memory > 80% capacity
- Database table size growing unexpectedly
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- API Documentation: `/backend/src/features/stations/docs/API.md`
- Deployment Checklist: `/backend/src/features/stations/docs/deployment/DEPLOYMENT-CHECKLIST.md`
- Secrets Verification: `/backend/src/features/stations/docs/deployment/SECRETS-VERIFICATION.md`
- Production Readiness: `/backend/src/features/stations/docs/deployment/PRODUCTION-READINESS.md`

View File

@@ -0,0 +1,408 @@
# Gas Stations Feature - Production Readiness Checklist
## Overview
Complete production readiness checklist for the Gas Stations feature. This document ensures all components are properly configured, tested, and validated before deployment to production.
## Pre-Deployment Checklist
### Configuration
- [ ] Google Cloud project created and configured
- [ ] Google Maps API key created with restrictions
- [ ] Places API enabled in Google Cloud
- [ ] Maps JavaScript API enabled in Google Cloud
- [ ] API quota limits configured (prevent unexpected costs)
- [ ] Billing alerts configured ($50, $100, $500 thresholds)
- [ ] Secrets file created: `./secrets/app/google-maps-api-key.txt`
- [ ] Secrets mounted in docker-compose.yml (backend + frontend)
- [ ] Environment variables configured (SECRETS_DIR=/run/secrets)
- [ ] Database connection string configured
- [ ] Redis connection configured
- [ ] JWT authentication configured (Auth0)
### Database
- [ ] PostgreSQL database accessible
- [ ] All migrations files present in `migrations/` directory
- [ ] Database backup taken before migration
- [ ] Migrations tested in staging environment
- [ ] Rollback procedure documented and tested
- [ ] Database indexes created (verified via \d station_cache, \d saved_stations)
- [ ] UNIQUE constraints in place (user_id + place_id)
- [ ] Soft delete pattern implemented (deleted_at column)
### Code Quality
- [ ] All linters passing: `npm run lint` (backend + frontend)
- [ ] All type checks passing: `npm run type-check` (backend + frontend)
- [ ] All unit tests passing (>80% coverage)
- [ ] All integration tests passing
- [ ] No console.log statements in production code
- [ ] No commented-out code
- [ ] No critical TODO comments
- [ ] Code reviewed by peer (if applicable)
- [ ] Security audit passed: `npm audit --production`
### Testing
- [ ] Unit tests written for service layer
- [ ] Unit tests written for repository layer
- [ ] Unit tests written for Google Maps client
- [ ] Integration tests written for all API endpoints
- [ ] User isolation tested (multiple users, data separation)
- [ ] Error scenarios tested (404, 500, circuit breaker)
- [ ] Performance benchmarks met (<1.5s search, <100ms CRUD)
- [ ] Load testing performed (optional but recommended)
- [ ] Manual testing on desktop browser
- [ ] Manual testing on mobile browser
- [ ] Manual testing with real Google Maps API key
### Security
- [ ] API key restricted by IP (backend)
- [ ] API key restricted by domain (frontend)
- [ ] API key restricted to required APIs only
- [ ] Secrets never logged in application
- [ ] Secrets not in environment variables
- [ ] Secrets not in Docker images
- [ ] SQL injection prevention (parameterized queries only)
- [ ] JWT validation enforced on all endpoints
- [ ] User data isolation validated (user_id filtering)
- [ ] CORS configured correctly
- [ ] Rate limiting considered (optional, for abuse prevention)
### Documentation
- [ ] Backend architecture documented
- [ ] API endpoints documented with examples
- [ ] Testing guide complete
- [ ] Google Maps setup guide complete
- [ ] Frontend components documented
- [ ] Deployment checklist complete
- [ ] Database migration guide complete
- [ ] Secrets verification guide complete
- [ ] Health checks guide complete
- [ ] Troubleshooting guide complete
- [ ] Main README.md updated with Gas Stations feature
## Deployment Checklist
### Pre-Deployment Tasks
- [ ] All pre-deployment checklist items completed
- [ ] Staging environment tested successfully
- [ ] Deployment window scheduled (maintenance window if needed)
- [ ] Rollback plan documented and tested
- [ ] Database backup taken
- [ ] Team notified of deployment
- [ ] Monitoring dashboard prepared
### Deployment Steps
1. [ ] Stop services: `docker compose down`
2. [ ] Pull latest code: `git pull origin main`
3. [ ] Install dependencies: `npm ci` (backend + frontend)
4. [ ] Run database migrations: `npm run migrate`
5. [ ] Verify migrations: Check tables and indexes exist
6. [ ] Configure secrets: Verify secret files mounted
7. [ ] Build containers: `make rebuild`
8. [ ] Start services: `docker compose up -d`
9. [ ] Verify all containers running: `docker compose ps`
10. [ ] Check container logs: `docker compose logs`
11. [ ] Wait for services to be ready (30-60 seconds)
### Post-Deployment Validation
#### Immediate Validation (within 5 minutes)
- [ ] Health endpoint responding: `curl http://localhost:3001/health`
- [ ] Backend logs show no errors: `docker compose logs mvp-backend | grep -i error`
- [ ] Frontend logs show no errors: `docker compose logs mvp-frontend | grep -i error`
- [ ] Database tables exist: `\dt station*`
- [ ] Redis connection working: `redis-cli ping`
- [ ] Secrets loaded in backend: Check logs for success message
- [ ] Frontend config.js generated: `cat /usr/share/nginx/html/config.js`
#### API Validation (within 15 minutes)
- [ ] GET /api/stations/saved responds (with JWT)
- [ ] POST /api/stations/search responds (with JWT, valid coordinates)
- [ ] POST /api/stations/save works (after search)
- [ ] PATCH /api/stations/saved/:id works
- [ ] DELETE /api/stations/saved/:id works
- [ ] Response times meet requirements (<1.5s search, <100ms CRUD)
- [ ] Error responses formatted correctly (401, 404, 500)
#### Frontend Validation (within 15 minutes)
- [ ] Frontend loads: https://motovaultpro.com
- [ ] No console errors in browser
- [ ] Config loads: `window.CONFIG.googleMapsApiKey` defined
- [ ] Stations page accessible
- [ ] Search form renders
- [ ] "Use Current Location" button works (geolocation permission)
- [ ] Search completes and displays results
- [ ] Map loads and displays markers
- [ ] Clicking marker shows station details
- [ ] Save button works
- [ ] Saved tab shows saved stations
- [ ] Mobile view works (responsive layout)
#### User Isolation Validation (within 30 minutes)
- [ ] User 1 can save stations
- [ ] User 2 cannot see User 1's saved stations
- [ ] User 2 can save their own stations
- [ ] Deleting User 1's station doesn't affect User 2
#### Performance Validation (within 30 minutes)
- [ ] Search response time: <1500ms (average of 10 requests)
- [ ] Save response time: <100ms
- [ ] Get saved response time: <100ms
- [ ] Delete response time: <100ms
- [ ] No memory leaks (check container memory after 100+ requests)
- [ ] No database connection leaks (check active connections)
#### External Services Validation (within 30 minutes)
- [ ] Google Maps API responding
- [ ] API usage appears in Google Cloud Console
- [ ] Circuit breaker not opening
- [ ] No quota exceeded errors
- [ ] Map displays correctly in browser
- [ ] Station markers clickable
## Post-Deployment Monitoring
### Hour 1 Monitoring
- [ ] Check logs every 15 minutes for errors
- [ ] Monitor API response times
- [ ] Verify no 500 errors
- [ ] Check Google Maps API usage
- [ ] Monitor database connections
- [ ] Monitor Redis memory
### Day 1 Monitoring
- [ ] Review logs for warnings/errors
- [ ] Check performance metrics
- [ ] Verify user adoption (if metrics available)
- [ ] Monitor Google Maps API costs
- [ ] Check for user-reported issues
- [ ] Verify circuit breaker not triggering
### Week 1 Monitoring
- [ ] Daily log review
- [ ] Performance trend analysis
- [ ] API usage trend analysis
- [ ] Database growth monitoring
- [ ] User feedback collection
- [ ] Cost analysis (Google Maps API)
## Rollback Criteria
Rollback immediately if:
- [ ] Health check fails consistently
- [ ] Backend container crashes repeatedly
- [ ] Database migrations fail
- [ ] >10% of API requests fail
- [ ] Response times >5 seconds consistently
- [ ] Google Maps API quota exceeded unexpectedly
- [ ] Circuit breaker opens >10 times in 1 hour
- [ ] Critical security vulnerability discovered
- [ ] User data integrity compromised
## Rollback Procedure
If rollback is needed:
1. [ ] Stop containers: `docker compose down`
2. [ ] Restore previous code: `git checkout <PREVIOUS_COMMIT>`
3. [ ] Restore database backup: `psql < backup_YYYYMMDD.sql`
4. [ ] Restore secrets backup (if rotated): `cp google-maps-api-key.txt.bak google-maps-api-key.txt`
5. [ ] Rebuild containers: `make rebuild`
6. [ ] Start services: `docker compose up -d`
7. [ ] Verify rollback successful: Run health checks
8. [ ] Notify team of rollback
9. [ ] Document rollback reason
10. [ ] Schedule post-mortem meeting
## Production Readiness Sign-Off
### Development Team
- [ ] Code complete and tested
- [ ] Documentation complete
- [ ] Peer review passed
- [ ] Unit tests passing
- [ ] Integration tests passing
**Signed**: _______________ Date: ___________
### QA Team (if applicable)
- [ ] Manual testing complete
- [ ] User acceptance testing passed
- [ ] Performance testing passed
- [ ] Security testing passed
- [ ] Browser compatibility tested
**Signed**: _______________ Date: ___________
### DevOps Team (if applicable)
- [ ] Infrastructure ready
- [ ] Secrets configured
- [ ] Monitoring configured
- [ ] Backup procedures in place
- [ ] Rollback plan validated
**Signed**: _______________ Date: ___________
### Product Owner (if applicable)
- [ ] Feature meets requirements
- [ ] Acceptance criteria met
- [ ] User stories completed
- [ ] Business value validated
**Signed**: _______________ Date: ___________
## Launch Communication
### Pre-Launch (24 hours before)
- [ ] Notify users of upcoming feature (if applicable)
- [ ] Announce maintenance window (if needed)
- [ ] Prepare support documentation
- [ ] Brief support team on new feature
### Launch Day
- [ ] Announce feature availability
- [ ] Monitor for user feedback
- [ ] Be available for support escalations
- [ ] Track adoption metrics
### Post-Launch (Week 1)
- [ ] Collect user feedback
- [ ] Monitor usage metrics
- [ ] Address any issues quickly
- [ ] Document lessons learned
## Success Criteria
### Technical Metrics
- [ ] All API endpoints responding
- [ ] <1% error rate
- [ ] Response times within requirements
- [ ] No unplanned downtime
- [ ] Circuit breaker <1% open rate
- [ ] Google Maps API costs within budget
### Business Metrics (if applicable)
- [ ] User adoption rate (X% of users use feature)
- [ ] User engagement (average searches per user per day)
- [ ] User satisfaction (feedback score >4/5)
- [ ] Feature retention (users return to feature)
### Operational Metrics
- [ ] No critical incidents
- [ ] Support tickets <X per day
- [ ] Mean time to resolution <X hours
- [ ] Documentation accuracy validated
- [ ] Team trained on feature
## Continuous Improvement
### Weekly Review
- [ ] Review error logs
- [ ] Analyze performance trends
- [ ] Monitor API costs
- [ ] Collect user feedback
- [ ] Identify improvement opportunities
### Monthly Review
- [ ] Code quality review
- [ ] Security audit
- [ ] Performance optimization
- [ ] Cost optimization
- [ ] Feature enhancement planning
### Quarterly Review
- [ ] Architecture review
- [ ] Technology stack review
- [ ] Competitive analysis
- [ ] Strategic roadmap alignment
## Emergency Contacts
### Google Maps API Issues
- Google Cloud Support: https://cloud.google.com/support
- API Key Dashboard: https://console.cloud.google.com/apis/credentials
### Infrastructure Issues
- Database: Review connection pool and query logs
- Redis: Check memory and connection status
- Docker: Check container health and logs
### Application Issues
- Backend: Review backend logs and circuit breaker state
- Frontend: Check browser console and network tab
- API: Test endpoints with curl and JWT
## Maintenance Windows
### Planned Maintenance
Schedule maintenance windows for:
- Database migrations
- API key rotation
- Dependency updates
- Security patches
**Recommended**: Monthly maintenance window, 2-4 AM local time, 30-minute duration
### Emergency Maintenance
For critical issues requiring immediate maintenance:
1. Notify users (if possible)
2. Take database backup
3. Execute fix
4. Validate fix
5. Document incident
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- API Documentation: `/backend/src/features/stations/docs/API.md`
- Testing Guide: `/backend/src/features/stations/docs/TESTING.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Deployment Checklist: `/backend/src/features/stations/docs/deployment/DEPLOYMENT-CHECKLIST.md`
- Database Migrations: `/backend/src/features/stations/docs/deployment/DATABASE-MIGRATIONS.md`
- Secrets Verification: `/backend/src/features/stations/docs/deployment/SECRETS-VERIFICATION.md`
- Health Checks: `/backend/src/features/stations/docs/deployment/HEALTH-CHECKS.md`
- Frontend Documentation: `/frontend/src/features/stations/README.md`
---
**Final Approval for Production Deployment**
I certify that all items on this production readiness checklist have been completed and validated. The Gas Stations feature is ready for production deployment.
**Name**: _______________
**Role**: _______________
**Signature**: _______________
**Date**: ___________

View File

@@ -0,0 +1,504 @@
# Gas Stations Feature - Secrets Verification Guide
## Overview
Complete guide for verifying Google Maps API key secrets are correctly configured in both backend and frontend containers. This document covers verification commands, troubleshooting steps, and security validation.
## Secrets Architecture
### Backend Secret Flow
```
Host filesystem: ./secrets/app/google-maps-api-key.txt
↓ (Docker volume mount)
Container: /run/secrets/google-maps-api-key
↓ (Config loader reads at startup)
Backend service: process.env or config object
↓ (Used in Google Maps client)
Google Places API calls
```
### Frontend Secret Flow
```
Host filesystem: ./secrets/app/google-maps-api-key.txt
↓ (Docker volume mount)
Container: /run/secrets/google-maps-api-key
↓ (Entrypoint script reads at startup)
Generated file: /usr/share/nginx/html/config.js
↓ (Loaded in index.html)
Browser: window.CONFIG.googleMapsApiKey
↓ (Used in maps-loader.ts)
Google Maps JavaScript API
```
## Verification Commands
### 1. Host Filesystem Verification
**Check secret file exists**:
```bash
ls -la ./secrets/app/google-maps-api-key.txt
```
**Expected Output**:
```
-rw-r--r-- 1 user user 39 Jan 15 10:00 ./secrets/app/google-maps-api-key.txt
```
**View secret content** (only in development):
```bash
cat ./secrets/app/google-maps-api-key.txt
```
**Expected**: API key starting with `AIzaSy...` (39 characters)
**Check file permissions**:
```bash
stat -c "%a %n" ./secrets/app/google-maps-api-key.txt
```
**Expected**: `644` (readable by owner and group)
### 2. Backend Container Verification
**Check secret mounted in container**:
```bash
docker compose exec mvp-backend cat /run/secrets/google-maps-api-key
```
**Expected**: Same API key as host file
**Check secret file permissions in container**:
```bash
docker compose exec mvp-backend ls -la /run/secrets/google-maps-api-key
```
**Expected**: Read-only file (`:ro` mount flag)
**Check backend can access secret**:
```bash
docker compose exec mvp-backend node -e "
const fs = require('fs');
try {
const key = fs.readFileSync('/run/secrets/google-maps-api-key', 'utf8').trim();
console.log('Secret loaded, length:', key.length);
console.log('Starts with AIzaSy:', key.startsWith('AIzaSy'));
} catch (error) {
console.error('Failed to read secret:', error.message);
}
"
```
**Expected Output**:
```
Secret loaded, length: 39
Starts with AIzaSy: true
```
**Check backend logs for secret loading**:
```bash
docker compose logs mvp-backend | grep -i "google.*api.*key"
```
**Expected** (log message should NOT show actual key, only status):
```
[Config] Google Maps API key loaded successfully
```
### 3. Frontend Container Verification
**Check secret mounted in container**:
```bash
docker compose exec mvp-frontend cat /run/secrets/google-maps-api-key
```
**Expected**: Same API key as host file
**Check config.js generated**:
```bash
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
```
**Expected Output**:
```javascript
window.CONFIG = {
googleMapsApiKey: 'AIzaSyYourActualKeyHere'
};
```
**Check entrypoint script ran**:
```bash
docker compose logs mvp-frontend | grep -i config
```
**Expected Output**:
```
[Config] Loaded Google Maps API key from /run/secrets/google-maps-api-key
[Config] Generated /usr/share/nginx/html/config.js
```
**Check config.js is served**:
```bash
curl -s http://localhost:3000/config.js
```
**Expected**: JavaScript file with `window.CONFIG = {...}`
### 4. Browser Verification
**Check config loaded in browser console**:
1. Open https://motovaultpro.com in browser
2. Open Developer Tools (F12)
3. Go to Console tab
4. Run:
```javascript
console.log(window.CONFIG);
```
**Expected Output**:
```javascript
{
googleMapsApiKey: "AIzaSyYourActualKeyHere"
}
```
**Check Google Maps script loads**:
```javascript
console.log(typeof google !== 'undefined' ? 'Google Maps loaded' : 'Not loaded yet');
```
**Check for config errors**:
```javascript
// Should not show any errors related to config
console.log(document.querySelectorAll('script[src*="config.js"]').length);
// Should return: 1
```
### 5. API Integration Verification
**Test backend can call Google Maps API**:
```bash
docker compose exec mvp-backend node -e "
const https = require('https');
const fs = require('fs');
const apiKey = fs.readFileSync('/run/secrets/google-maps-api-key', 'utf8').trim();
const url = \`https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=5000&type=gas_station&key=\${apiKey}\`;
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
const json = JSON.parse(data);
console.log('API Status:', json.status);
console.log('Results:', json.results?.length || 0, 'stations');
});
}).on('error', (err) => {
console.error('API Error:', err.message);
});
"
```
**Expected Output**:
```
API Status: OK
Results: 20 stations
```
**Test full API workflow with JWT**:
```bash
# Get JWT token (from Auth0 or test token)
export TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
# Search for stations
curl -X POST http://localhost:3001/api/stations/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"latitude": 37.7749,
"longitude": -122.4194,
"radius": 5000
}' | jq '.stations | length'
```
**Expected**: Number greater than 0 (indicating stations found)
## Security Validation
### 1. Secret Not in Environment Variables
**Check backend environment**:
```bash
docker compose exec mvp-backend env | grep -i google
```
**Expected**: No GOOGLE_MAPS_API_KEY in environment (should use file mount)
**Check frontend environment**:
```bash
docker compose exec mvp-frontend env | grep -i google
```
**Expected**: No GOOGLE_MAPS_API_KEY in environment
### 2. Secret Not in Docker Images
**Check backend image**:
```bash
docker history motovaultpro-backend | grep -i google
```
**Expected**: No API key in image layers
**Check frontend image**:
```bash
docker history motovaultpro-frontend | grep -i google
```
**Expected**: No API key in image layers
### 3. Secret Not in Logs
**Check all logs for API key exposure**:
```bash
docker compose logs | grep -i "AIzaSy"
```
**Expected**: No matches (API key should never be logged)
**Check for "secret" or "key" logging**:
```bash
docker compose logs | grep -i "api.*key" | grep -v "loaded successfully"
```
**Expected**: Only status messages, no actual key values
### 4. File Permissions Secure
**Check host file not world-readable**:
```bash
ls -l ./secrets/app/google-maps-api-key.txt | awk '{print $1}'
```
**Expected**: `-rw-r--r--` or stricter (not `-rw-rw-rw-`)
**Check container mount is read-only**:
```bash
docker compose config | grep -A5 "google-maps-api-key"
```
**Expected**: Contains `:ro` flag
### 5. Secret Rotation Test
**Update secret and verify change propagates**:
```bash
# Backup current secret
cp ./secrets/app/google-maps-api-key.txt ./secrets/app/google-maps-api-key.txt.bak
# Update with new key (test key for rotation testing)
echo "AIzaSyTestKeyForRotation" > ./secrets/app/google-maps-api-key.txt
# Restart containers
docker compose restart mvp-backend mvp-frontend
# Wait for startup
sleep 5
# Verify new key loaded in backend
docker compose exec mvp-backend cat /run/secrets/google-maps-api-key
# Verify new key in frontend config.js
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Restore original key
mv ./secrets/app/google-maps-api-key.txt.bak ./secrets/app/google-maps-api-key.txt
# Restart again
docker compose restart mvp-backend mvp-frontend
```
## Troubleshooting
### Backend Can't Read Secret
**Symptom**:
```bash
docker compose logs mvp-backend | grep -i "google"
# Shows: Failed to load Google Maps API key
```
**Solutions**:
1. **Check file exists on host**:
```bash
ls -la ./secrets/app/google-maps-api-key.txt
```
2. **Check docker-compose.yml mount**:
```bash
grep -A5 "mvp-backend:" docker-compose.yml | grep google-maps
```
Should show:
```yaml
- ./secrets/app/google-maps-api-key.txt:/run/secrets/google-maps-api-key:ro
```
3. **Check file in container**:
```bash
docker compose exec mvp-backend ls -la /run/secrets/
```
4. **Restart backend**:
```bash
docker compose restart mvp-backend
```
### Frontend config.js Not Generated
**Symptom**:
```bash
docker compose exec mvp-frontend cat /usr/share/nginx/html/config.js
# Shows: No such file or directory
```
**Solutions**:
1. **Check entrypoint script exists**:
```bash
docker compose exec mvp-frontend ls -la /app/load-config.sh
```
2. **Check entrypoint runs**:
```bash
docker compose logs mvp-frontend | head -20
```
Should show config generation messages.
3. **Run entrypoint manually**:
```bash
docker compose exec mvp-frontend sh /app/load-config.sh
```
4. **Restart frontend**:
```bash
docker compose restart mvp-frontend
```
### Browser Shows "API key undefined"
**Symptom**: Browser console shows `window.CONFIG.googleMapsApiKey` is undefined
**Solutions**:
1. **Check config.js loads**:
Open browser DevTools > Network tab > Refresh page > Look for `config.js`
2. **Check config.js content**:
```bash
curl http://localhost:3000/config.js
```
3. **Check index.html loads config.js**:
View page source, look for:
```html
<script src="/config.js"></script>
```
4. **Clear browser cache**:
Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
### Google API Returns "Invalid API Key"
**Symptom**: API calls return 400 error with "The provided API key is invalid"
**Solutions**:
1. **Verify key format**:
```bash
cat ./secrets/app/google-maps-api-key.txt | wc -c
```
Should be 39 characters (including newline) or 40.
2. **Check for extra whitespace**:
```bash
cat ./secrets/app/google-maps-api-key.txt | od -c
```
Should only show key and newline, no spaces/tabs.
3. **Verify key in Google Cloud Console**:
- Go to APIs & Services > Credentials
- Confirm key exists and is not deleted
4. **Check API restrictions**:
- Verify IP/domain restrictions don't block your server
- Verify API restrictions include Places API
### Secret File Permissions Wrong
**Symptom**: Container can't read secret file
**Solutions**:
```bash
# Fix file permissions
chmod 644 ./secrets/app/google-maps-api-key.txt
# Verify ownership
ls -l ./secrets/app/google-maps-api-key.txt
# Restart containers
docker compose restart mvp-backend mvp-frontend
```
## Automated Verification Script
Create a verification script for convenience:
```bash
#!/bin/bash
# verify-secrets.sh
echo "=== Gas Stations Secrets Verification ==="
echo ""
echo "1. Host file exists:"
[ -f ./secrets/app/google-maps-api-key.txt ] && echo "✓ PASS" || echo "✗ FAIL"
echo ""
echo "2. Backend can read secret:"
docker compose exec -T mvp-backend cat /run/secrets/google-maps-api-key >/dev/null 2>&1 && echo "✓ PASS" || echo "✗ FAIL"
echo ""
echo "3. Frontend can read secret:"
docker compose exec -T mvp-frontend cat /run/secrets/google-maps-api-key >/dev/null 2>&1 && echo "✓ PASS" || echo "✗ FAIL"
echo ""
echo "4. Frontend config.js generated:"
docker compose exec -T mvp-frontend cat /usr/share/nginx/html/config.js >/dev/null 2>&1 && echo "✓ PASS" || echo "✗ FAIL"
echo ""
echo "5. API key format valid:"
KEY=$(cat ./secrets/app/google-maps-api-key.txt)
[[ $KEY =~ ^AIzaSy ]] && echo "✓ PASS" || echo "✗ FAIL"
echo ""
echo "=== Verification Complete ==="
```
**Usage**:
```bash
chmod +x verify-secrets.sh
./verify-secrets.sh
```
## References
- Architecture: `/backend/src/features/stations/docs/ARCHITECTURE.md`
- Google Maps Setup: `/backend/src/features/stations/docs/GOOGLE-MAPS-SETUP.md`
- Deployment Checklist: `/backend/src/features/stations/docs/deployment/DEPLOYMENT-CHECKLIST.md`
- Runtime Config: `/frontend/docs/RUNTIME-CONFIG.md`

View File

@@ -0,0 +1,65 @@
/**
* @ai-summary Google Maps API circuit breaker
* @ai-context Wraps Google Maps API calls with resilience pattern
*/
import CircuitBreaker from 'opossum';
import { logger } from '../../../../core/logging/logger';
/**
* Google Maps API Circuit Breaker Configuration
* Prevents cascading failures when Google Maps API is unavailable
*
* Configuration:
* - timeout: 10s max wait for API response
* - errorThresholdPercentage: 50% error rate triggers open
* - resetTimeout: 30s before attempting recovery
* - name: identifier for logging
*/
export function createGoogleMapsCircuitBreaker<T>(
asyncFn: () => Promise<T>,
operationName: string
): CircuitBreaker {
const breaker = new CircuitBreaker(asyncFn, {
timeout: 10000, // 10 seconds
errorThresholdPercentage: 50,
resetTimeout: 30000, // 30 seconds
name: `google-maps-${operationName}`,
volumeThreshold: 10, // Minimum requests before opening
rollingCountTimeout: 10000 // Window for counting requests
});
// Log circuit state changes for monitoring
breaker.on('open', () => {
logger.warn(`Circuit breaker opened for Google Maps ${operationName}`);
});
breaker.on('halfOpen', () => {
logger.info(`Circuit breaker half-open for Google Maps ${operationName}`);
});
breaker.on('close', () => {
logger.info(`Circuit breaker closed for Google Maps ${operationName}`);
});
return breaker;
}
/**
* Execute function through circuit breaker
* Falls back to null if circuit is open or function fails
*/
export async function executeWithCircuitBreaker<T>(
breaker: CircuitBreaker,
asyncFn: () => Promise<T>
): Promise<T | null> {
try {
return (await breaker.fire(asyncFn)) as T;
} catch (error) {
logger.error('Circuit breaker execution failed', {
error: error instanceof Error ? error.message : String(error),
brearerName: breaker.name
});
return null;
}
}

View File

@@ -75,22 +75,34 @@ export class GoogleMapsClient {
// Generate photo URL if available
let photoUrl: string | undefined;
if (place.photos && place.photos.length > 0) {
if (place.photos && place.photos.length > 0 && place.photos[0]) {
photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`;
}
return {
const station: Station = {
id: place.place_id,
placeId: place.place_id,
name: place.name,
address: place.vicinity,
latitude: place.geometry.location.lat,
longitude: place.geometry.location.lng,
distance,
isOpen: place.opening_hours?.open_now,
rating: place.rating,
photoUrl
distance
};
// Only set optional properties if defined
if (photoUrl !== undefined) {
station.photoUrl = photoUrl;
}
if (place.opening_hours?.open_now !== undefined) {
station.isOpen = place.opening_hours.open_now;
}
if (place.rating !== undefined) {
station.rating = place.rating;
}
return station;
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {

View File

@@ -0,0 +1,79 @@
/**
* @ai-summary Scheduled cache cleanup job for stations
*/
import { Pool } from 'pg';
import { logger } from '../../../core/logging/logger';
/**
* Clean up expired station cache entries
* Runs daily at 2 AM
* Deletes entries older than 24 hours
*/
export async function cleanupStationCache(pool: Pool): Promise<void> {
const startTime = Date.now();
try {
logger.info('Starting station cache cleanup job');
// Calculate timestamp for entries older than 24 hours
const cutoffTime = new Date(Date.now() - 24 * 60 * 60 * 1000);
// Execute cleanup query
const result = await pool.query(
`DELETE FROM station_cache
WHERE created_at < $1
RETURNING id`,
[cutoffTime]
);
const deletedCount = result.rowCount || 0;
const duration = Date.now() - startTime;
logger.info('Station cache cleanup completed', {
deletedCount,
durationMs: duration,
cutoffTime: cutoffTime.toISOString()
});
// Log warning if cleanup took longer than expected
if (duration > 5000) {
logger.warn('Station cache cleanup took longer than expected', {
durationMs: duration
});
}
} catch (error) {
logger.error('Station cache cleanup failed', {
error: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startTime
});
// Re-throw to let scheduler handle failure
throw error;
}
}
/**
* Get cache statistics for monitoring
*/
export async function getCacheStats(pool: Pool): Promise<{
totalEntries: number;
oldestEntry: Date | null;
newestEntry: Date | null;
}> {
const result = await pool.query(
`SELECT
COUNT(*) as total,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM station_cache`
);
const row = result.rows[0];
return {
totalEntries: parseInt(row.total, 10),
oldestEntry: row.oldest ? new Date(row.oldest) : null,
newestEntry: row.newest ? new Date(row.newest) : null
};
}

View File

@@ -0,0 +1,95 @@
/**
* @ai-summary Mock Google Places API responses for tests
*/
import { GooglePlacesResponse } from '../../external/google-maps/google-maps.types';
export const mockGoogleNearbySearchResponse: GooglePlacesResponse = {
results: [
{
geometry: {
location: {
lat: 37.7749,
lng: -122.4194
}
},
name: 'Shell Gas Station - Downtown',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
vicinity: '123 Main St, San Francisco, CA 94105',
rating: 4.2,
photos: [
{
photo_reference: 'photo_ref_1'
}
],
opening_hours: {
open_now: true
},
types: ['gas_station', 'point_of_interest', 'establishment']
},
{
geometry: {
location: {
lat: 37.7923,
lng: -122.3989
}
},
name: 'Chevron Station - Financial District',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLde',
vicinity: '456 Market St, San Francisco, CA 94102',
rating: 4.5,
photos: [
{
photo_reference: 'photo_ref_2'
}
],
opening_hours: {
open_now: true
},
types: ['gas_station', 'point_of_interest', 'establishment']
}
],
status: 'OK'
};
export const mockGooglePlaceDetailsResponse = {
result: {
geometry: {
location: {
lat: 37.7749,
lng: -122.4194
}
},
name: 'Shell Gas Station - Downtown',
place_id: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
formatted_address: '123 Main St, San Francisco, CA 94105',
rating: 4.2,
user_ratings_total: 150,
formatted_phone_number: '+1 (415) 555-0100',
website: 'https://www.shell.com',
opening_hours: {
weekday_text: [
'Monday: 12:00 AM 11:59 PM',
'Tuesday: 12:00 AM 11:59 PM'
]
},
photos: [
{
photo_reference: 'photo_ref_1'
}
],
types: ['gas_station', 'point_of_interest', 'establishment']
},
status: 'OK'
};
export const mockGoogleErrorResponse = {
results: [],
status: 'ZERO_RESULTS'
};
export const mockGoogleApiErrorResponse = {
results: [],
status: 'REQUEST_DENIED',
error_message: 'Invalid API key'
};

View File

@@ -0,0 +1,79 @@
/**
* @ai-summary Mock station data for tests
*/
import { Station, SavedStation } from '../../domain/stations.types';
export const mockStations: Station[] = [
{
id: 'station-1',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLdc',
name: 'Shell Gas Station - Downtown',
address: '123 Main St, San Francisco, CA 94105',
latitude: 37.7749,
longitude: -122.4194,
rating: 4.2,
distance: 250,
photoUrl: 'https://example.com/shell-downtown.jpg',
priceRegular: 4.29,
pricePremium: 4.79,
priceDiesel: 4.49
},
{
id: 'station-2',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLde',
name: 'Chevron Station - Financial District',
address: '456 Market St, San Francisco, CA 94102',
latitude: 37.7923,
longitude: -122.3989,
rating: 4.5,
distance: 1200,
photoUrl: 'https://example.com/chevron-fd.jpg',
priceRegular: 4.39,
pricePremium: 4.89
},
{
id: 'station-3',
placeId: 'ChIJN1blFMzZrIEElx_JXUzRLdf',
name: 'Exxon Mobile - Mission',
address: '789 Valencia St, San Francisco, CA 94103',
latitude: 37.7599,
longitude: -122.4148,
rating: 3.8,
distance: 1850,
photoUrl: 'https://example.com/exxon-mission.jpg',
priceRegular: 4.19,
priceDiesel: 4.39
}
];
export const mockSavedStations: SavedStation[] = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
userId: 'user123',
stationId: mockStations[0].placeId,
nickname: 'Work Gas Station',
notes: 'Usually has good prices, rewards program available',
isFavorite: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15')
},
{
id: '550e8400-e29b-41d4-a716-446655440001',
userId: 'user123',
stationId: mockStations[1].placeId,
nickname: 'Home Station',
notes: 'Closest to apartment',
isFavorite: true,
createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-10')
}
];
export const searchCoordinates = {
sanFrancisco: { latitude: 37.7749, longitude: -122.4194 },
losAngeles: { latitude: 34.0522, longitude: -118.2437 },
seattle: { latitude: 47.6062, longitude: -122.3321 }
};
export const mockUserId = 'user123';

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,168 @@
/**
* @ai-summary Unit tests for Google Maps client
*/
import axios from 'axios';
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
import {
mockGoogleNearbySearchResponse,
mockGoogleErrorResponse,
mockGoogleApiErrorResponse
} from '../fixtures/mock-google-response';
import { searchCoordinates } from '../fixtures/mock-stations';
jest.mock('axios');
jest.mock('../../../../core/config/redis');
jest.mock('../../../../core/logging/logger');
describe('GoogleMapsClient', () => {
let client: GoogleMapsClient;
let mockAxios: jest.Mocked<typeof axios>;
beforeEach(() => {
jest.clearAllMocks();
mockAxios = axios as jest.Mocked<typeof axios>;
client = new GoogleMapsClient();
});
describe('searchNearbyStations', () => {
it('should search for nearby gas stations', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude,
5000
);
expect(result).toHaveLength(2);
expect(result[0]?.name).toBe('Shell Gas Station - Downtown');
expect(mockAxios.get).toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleApiErrorResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result).toEqual([]);
});
it('should handle zero results', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleErrorResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.seattle.latitude,
searchCoordinates.seattle.longitude
);
expect(result).toEqual([]);
});
it('should handle network errors', async () => {
mockAxios.get.mockRejectedValue(new Error('Network error'));
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result).toEqual([]);
});
it('should calculate distance from reference point', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result[0]?.distance).toBeGreaterThan(0);
});
it('should format API response correctly', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
const result = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result[0]).toHaveProperty('placeId');
expect(result[0]).toHaveProperty('name');
expect(result[0]).toHaveProperty('address');
expect(result[0]).toHaveProperty('latitude');
expect(result[0]).toHaveProperty('longitude');
expect(result[0]).toHaveProperty('rating');
});
it('should respect custom radius parameter', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude,
2000
);
const callArgs = mockAxios.get.mock.calls[0]?.[1];
expect(callArgs?.params?.radius).toBe(2000);
});
});
describe('caching', () => {
it('should cache search results', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
// First call should hit API
const result1 = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
// Second call should return cached result
const result2 = await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
expect(result1).toEqual(result2);
});
it('should use different cache keys for different coordinates', async () => {
mockAxios.get.mockResolvedValue({
data: mockGoogleNearbySearchResponse
});
await client.searchNearbyStations(
searchCoordinates.sanFrancisco.latitude,
searchCoordinates.sanFrancisco.longitude
);
await client.searchNearbyStations(
searchCoordinates.losAngeles.latitude,
searchCoordinates.losAngeles.longitude
);
expect(mockAxios.get).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* @ai-summary Unit tests for StationsService
*/
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import {
mockStations,
mockSavedStations,
mockUserId,
searchCoordinates
} from '../fixtures/mock-stations';
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client');
describe('StationsService', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
beforeEach(() => {
jest.clearAllMocks();
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn(),
deleteSavedStation: jest.fn()
} as unknown as jest.Mocked<StationsRepository>;
(StationsRepository as jest.Mock).mockImplementation(() => mockRepository);
(googleMapsClient.searchNearbyStations as jest.Mock) = jest.fn().mockResolvedValue(mockStations);
service = new StationsService(mockRepository);
});
describe('searchNearbyStations', () => {
it('should search for nearby stations and cache results', async () => {
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude,
radius: 5000
},
mockUserId
);
expect(result.stations).toHaveLength(3);
expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown');
expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3);
});
it('should sort stations by distance', async () => {
const stationsWithDistance = [
{ ...mockStations[0], distance: 500 },
{ ...mockStations[1], distance: 100 },
{ ...mockStations[2], distance: 2000 }
];
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(
stationsWithDistance
);
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
},
mockUserId
);
expect(result.stations[0]?.distance).toBe(100);
expect(result.stations[1]?.distance).toBe(500);
expect(result.stations[2]?.distance).toBe(2000);
});
it('should return search metadata', async () => {
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude,
radius: 3000
},
mockUserId
);
expect(result.searchLocation.latitude).toBe(
searchCoordinates.sanFrancisco.latitude
);
expect(result.searchLocation.longitude).toBe(
searchCoordinates.sanFrancisco.longitude
);
expect(result.searchRadius).toBe(3000);
expect(result.timestamp).toBeDefined();
});
it('should use default radius if not provided', async () => {
await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
},
mockUserId
);
expect(mockRepository.cacheStation).toHaveBeenCalled();
});
});
describe('saveStation', () => {
it('should save a station from cache', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const stationId = station.placeId;
mockRepository.getCachedStation.mockResolvedValue(station);
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.saveStation.mockResolvedValue(savedStation);
const result = await service.saveStation(stationId, mockUserId, {
nickname: 'Work Gas Station'
});
expect(mockRepository.getCachedStation).toHaveBeenCalledWith(stationId);
expect(mockRepository.saveStation).toHaveBeenCalledWith(
mockUserId,
stationId,
{ nickname: 'Work Gas Station' }
);
expect(result).toHaveProperty('id');
});
it('should throw error if station not in cache', async () => {
mockRepository.getCachedStation.mockResolvedValue(null);
await expect(
service.saveStation('unknown-id', mockUserId)
).rejects.toThrow('Station not found');
});
it('should save station with custom metadata', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.getCachedStation.mockResolvedValue(station);
mockRepository.saveStation.mockResolvedValue(savedStation);
await service.saveStation(station.placeId, mockUserId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
});
expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
});
});
});
describe('getUserSavedStations', () => {
it('should return all saved stations for user', async () => {
const station = mockStations[0];
if (!station) throw new Error('Mock station not found');
mockRepository.getUserSavedStations.mockResolvedValue(mockSavedStations);
mockRepository.getCachedStation.mockResolvedValue(station);
const result = await service.getUserSavedStations(mockUserId);
expect(mockRepository.getUserSavedStations).toHaveBeenCalledWith(mockUserId);
expect(result).toBeDefined();
expect(result.length).toBe(mockSavedStations.length);
});
it('should return empty array if user has no saved stations', async () => {
mockRepository.getUserSavedStations.mockResolvedValue([]);
const result = await service.getUserSavedStations('other-user');
expect(result).toEqual([]);
});
});
describe('removeSavedStation', () => {
it('should delete a saved station', async () => {
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.deleteSavedStation.mockResolvedValue(true);
await service.removeSavedStation(
savedStation.stationId,
mockUserId
);
expect(mockRepository.deleteSavedStation).toHaveBeenCalledWith(
mockUserId,
savedStation.stationId
);
});
it('should throw error if station not found', async () => {
mockRepository.deleteSavedStation.mockResolvedValue(false);
await expect(
service.removeSavedStation('non-existent', mockUserId)
).rejects.toThrow('Saved station not found');
});
it('should verify user isolation', async () => {
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.deleteSavedStation.mockResolvedValue(true);
await service.removeSavedStation(savedStation.stationId, 'other-user');
expect(mockRepository.deleteSavedStation).toHaveBeenCalledWith(
'other-user',
savedStation.stationId
);
});
});
});