Files
motovaultpro/docs/changes/fuel-logs-v1/FUEL-LOGS-PHASE-3.md
Eric Gullickson a052040e3a Initial Commit
2025-09-17 16:09:15 -05:00

932 lines
29 KiB
Markdown

# Phase 3: API & Backend Implementation
## Overview
Update API contracts, implement enhanced backend services, create new endpoints, and build comprehensive test suite for the enhanced fuel logs system.
## Prerequisites
- ✅ Phase 1 completed (database schema and core types)
- ✅ Phase 2 completed (enhanced business logic services)
- All business logic services tested and functional
## Updated Service Layer
### Enhanced Fuel Logs Service
**File**: `backend/src/features/fuel-logs/domain/fuel-logs.service.ts` (Updated)
```typescript
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import {
FuelLog, CreateFuelLogRequest, UpdateFuelLogRequest,
FuelLogResponse, FuelStats, UnitSystem
} from './fuel-logs.types';
import { EnhancedValidationService } from './enhanced-validation.service';
import { EfficiencyCalculationService } from './efficiency-calculation.service';
import { UnitConversionService } from './unit-conversion.service';
import { UserSettingsService } from '../external/user-settings.service';
import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis';
import pool from '../../../core/config/database';
export class FuelLogsService {
private readonly cachePrefix = 'fuel-logs';
private readonly cacheTTL = 300; // 5 minutes
constructor(private repository: FuelLogsRepository) {}
async createFuelLog(data: CreateFuelLogRequest, userId: string): Promise<FuelLogResponse> {
logger.info('Creating enhanced fuel log', {
userId,
vehicleId: data.vehicleId,
fuelType: data.fuelType,
hasTrip: !!data.tripDistance,
hasOdometer: !!data.odometerReading
});
// Get user settings for unit system
const userSettings = await UserSettingsService.getUserSettings(userId);
// Enhanced validation
const validation = EnhancedValidationService.validateFuelLogData(data, userSettings.unitSystem);
if (!validation.isValid) {
throw new ValidationError(`Invalid fuel log data: ${validation.errors.join(', ')}`);
}
// Log warnings
if (validation.warnings.length > 0) {
logger.warn('Fuel log validation warnings', { warnings: validation.warnings });
}
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[data.vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
// Calculate total cost
const totalCost = data.fuelUnits * data.costPerUnit;
// Get previous log for efficiency calculation
const previousLog = data.odometerReading ?
await this.repository.getPreviousLogByOdometer(data.vehicleId, data.odometerReading) :
await this.repository.getLatestLogForVehicle(data.vehicleId);
// Calculate efficiency
const efficiencyResult = EfficiencyCalculationService.calculateEfficiency(
{ ...data, totalCost },
previousLog,
userSettings.unitSystem
);
// Prepare fuel log data
const fuelLogData = {
...data,
userId,
dateTime: new Date(data.dateTime),
totalCost,
mpg: efficiencyResult?.value || null,
efficiencyCalculationMethod: efficiencyResult?.calculationMethod || null
};
// Create fuel log
const fuelLog = await this.repository.create(fuelLogData);
// Update vehicle odometer if provided
if (data.odometerReading) {
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND (odometer_reading IS NULL OR odometer_reading < $1)',
[data.odometerReading, data.vehicleId]
);
}
// Invalidate caches
await this.invalidateCaches(userId, data.vehicleId);
return this.toResponse(fuelLog, userSettings.unitSystem);
}
async getFuelLogsByVehicle(
vehicleId: string,
userId: string,
options?: { unitSystem?: UnitSystem }
): Promise<FuelLogResponse[]> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
// Get user settings
const userSettings = await UserSettingsService.getUserSettings(userId);
const unitSystem = options?.unitSystem || userSettings.unitSystem;
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}:${unitSystem}`;
// Check cache
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
if (cached) {
return cached;
}
// Get from database
const logs = await this.repository.findByVehicleId(vehicleId);
const response = logs.map((log: FuelLog) => this.toResponse(log, unitSystem));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getEnhancedVehicleStats(vehicleId: string, userId: string): Promise<EnhancedFuelStats> {
// Verify vehicle ownership
const vehicleCheck = await pool.query(
'SELECT id FROM vehicles WHERE id = $1 AND user_id = $2',
[vehicleId, userId]
);
if (vehicleCheck.rows.length === 0) {
throw new Error('Vehicle not found or unauthorized');
}
const userSettings = await UserSettingsService.getUserSettings(userId);
const logs = await this.repository.findByVehicleId(vehicleId);
if (logs.length === 0) {
return this.getEmptyStats(userSettings.unitSystem);
}
// Calculate comprehensive stats
const totalFuelUnits = logs.reduce((sum, log) => sum + log.fuelUnits, 0);
const totalCost = logs.reduce((sum, log) => sum + log.totalCost, 0);
const averageCostPerUnit = totalCost / totalFuelUnits;
const totalDistance = EfficiencyCalculationService.calculateTotalDistance(logs, userSettings.unitSystem);
const averageEfficiency = EfficiencyCalculationService.calculateAverageEfficiency(logs, userSettings.unitSystem);
// Group by fuel type
const fuelTypeBreakdown = this.calculateFuelTypeBreakdown(logs, userSettings.unitSystem);
// Calculate trends (last 30 days vs previous 30 days)
const trends = this.calculateEfficiencyTrends(logs, userSettings.unitSystem);
const unitLabels = UnitConversionService.getUnitLabels(userSettings.unitSystem);
return {
logCount: logs.length,
totalFuelUnits,
totalCost,
averageCostPerUnit,
totalDistance,
averageEfficiency: averageEfficiency?.value || 0,
fuelTypeBreakdown,
trends,
unitLabels,
dateRange: {
earliest: logs[logs.length - 1]?.dateTime,
latest: logs[0]?.dateTime
}
};
}
private toResponse(log: FuelLog, unitSystem: UnitSystem): FuelLogResponse {
const unitLabels = UnitConversionService.getUnitLabels(unitSystem);
// Convert efficiency to user's unit system if needed
let displayEfficiency = log.mpg;
if (log.mpg && unitSystem === UnitSystem.METRIC) {
displayEfficiency = UnitConversionService.convertEfficiency(
log.mpg,
UnitSystem.IMPERIAL, // Assuming stored as MPG
UnitSystem.METRIC
);
}
return {
id: log.id,
userId: log.userId,
vehicleId: log.vehicleId,
dateTime: log.dateTime.toISOString(),
// Distance information
odometerReading: log.odometerReading,
tripDistance: log.tripDistance,
// Fuel information
fuelType: log.fuelType,
fuelGrade: log.fuelGrade,
fuelUnits: log.fuelUnits,
costPerUnit: log.costPerUnit,
totalCost: log.totalCost,
// Location
locationData: log.locationData,
// Calculated fields
efficiency: displayEfficiency,
efficiencyLabel: unitLabels.efficiencyUnits,
// Metadata
notes: log.notes,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
// Legacy fields (for backward compatibility)
date: log.dateTime.toISOString().split('T')[0],
odometer: log.odometerReading,
gallons: log.fuelUnits, // May need conversion
pricePerGallon: log.costPerUnit, // May need conversion
mpg: log.mpg
};
}
}
```
### New API Endpoints
#### Fuel Grade Endpoint
**File**: `backend/src/features/fuel-logs/api/fuel-grade.controller.ts`
```typescript
import { FastifyRequest, FastifyReply } from 'fastify';
import { FuelGradeService } from '../domain/fuel-grade.service';
import { FuelType } from '../domain/fuel-logs.types';
import { logger } from '../../../core/logging/logger';
export class FuelGradeController {
async getFuelGrades(
request: FastifyRequest<{ Params: { fuelType: FuelType } }>,
reply: FastifyReply
) {
try {
const { fuelType } = request.params;
// Validate fuel type
if (!Object.values(FuelType).includes(fuelType)) {
return reply.code(400).send({
error: 'Bad Request',
message: `Invalid fuel type: ${fuelType}`
});
}
const grades = FuelGradeService.getFuelGradeOptions(fuelType);
return reply.code(200).send({
fuelType,
grades
});
} catch (error: any) {
logger.error('Error getting fuel grades', { error, fuelType: request.params.fuelType });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get fuel grades'
});
}
}
async getAllFuelTypes(request: FastifyRequest, reply: FastifyReply) {
try {
const fuelTypes = Object.values(FuelType).map(type => ({
value: type,
label: type.charAt(0).toUpperCase() + type.slice(1),
grades: FuelGradeService.getFuelGradeOptions(type)
}));
return reply.code(200).send({ fuelTypes });
} catch (error: any) {
logger.error('Error getting fuel types', { error });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get fuel types'
});
}
}
}
```
### Enhanced Routes
**File**: `backend/src/features/fuel-logs/api/fuel-logs.routes.ts` (Updated)
```typescript
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { FuelLogsController } from './fuel-logs.controller';
import { FuelGradeController } from './fuel-grade.controller';
import {
createFuelLogSchema,
updateFuelLogSchema,
fuelLogParamsSchema,
vehicleParamsSchema,
fuelTypeParamsSchema
} from './fuel-logs.validators';
export async function fuelLogsRoutes(
fastify: FastifyInstance,
options: FastifyPluginOptions
) {
const fuelLogsController = new FuelLogsController();
const fuelGradeController = new FuelGradeController();
// Existing fuel log CRUD endpoints (enhanced)
fastify.post('/fuel-logs', {
preHandler: [fastify.authenticate],
schema: createFuelLogSchema
}, fuelLogsController.createFuelLog.bind(fuelLogsController));
fastify.get('/fuel-logs', {
preHandler: [fastify.authenticate]
}, fuelLogsController.getUserFuelLogs.bind(fuelLogsController));
fastify.get('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: { params: fuelLogParamsSchema }
}, fuelLogsController.getFuelLog.bind(fuelLogsController));
fastify.put('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: {
params: fuelLogParamsSchema,
body: updateFuelLogSchema
}
}, fuelLogsController.updateFuelLog.bind(fuelLogsController));
fastify.delete('/fuel-logs/:id', {
preHandler: [fastify.authenticate],
schema: { params: fuelLogParamsSchema }
}, fuelLogsController.deleteFuelLog.bind(fuelLogsController));
// Vehicle-specific endpoints (enhanced)
fastify.get('/fuel-logs/vehicle/:vehicleId', {
preHandler: [fastify.authenticate],
schema: { params: vehicleParamsSchema }
}, fuelLogsController.getFuelLogsByVehicle.bind(fuelLogsController));
fastify.get('/fuel-logs/vehicle/:vehicleId/stats', {
preHandler: [fastify.authenticate],
schema: { params: vehicleParamsSchema }
}, fuelLogsController.getEnhancedVehicleStats.bind(fuelLogsController));
// NEW: Fuel type/grade endpoints
fastify.get('/fuel-logs/fuel-types', {
preHandler: [fastify.authenticate]
}, fuelGradeController.getAllFuelTypes.bind(fuelGradeController));
fastify.get('/fuel-logs/fuel-grades/:fuelType', {
preHandler: [fastify.authenticate],
schema: { params: fuelTypeParamsSchema }
}, fuelGradeController.getFuelGrades.bind(fuelGradeController));
}
export function registerFuelLogsRoutes(fastify: FastifyInstance) {
return fastify.register(fuelLogsRoutes, { prefix: '/api' });
}
```
### Enhanced Validation Schemas
**File**: `backend/src/features/fuel-logs/api/fuel-logs.validators.ts` (Updated)
```typescript
import { Type } from '@sinclair/typebox';
import { FuelType } from '../domain/fuel-logs.types';
export const createFuelLogSchema = {
body: Type.Object({
vehicleId: Type.String({ format: 'uuid' }),
dateTime: Type.String({ format: 'date-time' }),
// Distance (one required)
odometerReading: Type.Optional(Type.Number({ minimum: 0 })),
tripDistance: Type.Optional(Type.Number({ minimum: 0 })),
// Fuel system
fuelType: Type.Enum(FuelType),
fuelGrade: Type.Optional(Type.String()),
fuelUnits: Type.Number({ minimum: 0.01 }),
costPerUnit: Type.Number({ minimum: 0.01 }),
// Location (optional)
locationData: Type.Optional(Type.Object({
address: Type.Optional(Type.String()),
coordinates: Type.Optional(Type.Object({
latitude: Type.Number({ minimum: -90, maximum: 90 }),
longitude: Type.Number({ minimum: -180, maximum: 180 })
})),
googlePlaceId: Type.Optional(Type.String()),
stationName: Type.Optional(Type.String())
})),
notes: Type.Optional(Type.String({ maxLength: 500 }))
}),
response: {
201: Type.Object({
id: Type.String({ format: 'uuid' }),
userId: Type.String(),
vehicleId: Type.String({ format: 'uuid' }),
dateTime: Type.String({ format: 'date-time' }),
odometerReading: Type.Optional(Type.Number()),
tripDistance: Type.Optional(Type.Number()),
fuelType: Type.Enum(FuelType),
fuelGrade: Type.Optional(Type.String()),
fuelUnits: Type.Number(),
costPerUnit: Type.Number(),
totalCost: Type.Number(),
efficiency: Type.Optional(Type.Number()),
efficiencyLabel: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
})
}
};
export const updateFuelLogSchema = {
body: Type.Partial(Type.Object({
dateTime: Type.String({ format: 'date-time' }),
odometerReading: Type.Number({ minimum: 0 }),
tripDistance: Type.Number({ minimum: 0 }),
fuelType: Type.Enum(FuelType),
fuelGrade: Type.String(),
fuelUnits: Type.Number({ minimum: 0.01 }),
costPerUnit: Type.Number({ minimum: 0.01 }),
locationData: Type.Object({
address: Type.Optional(Type.String()),
coordinates: Type.Optional(Type.Object({
latitude: Type.Number({ minimum: -90, maximum: 90 }),
longitude: Type.Number({ minimum: -180, maximum: 180 })
})),
googlePlaceId: Type.Optional(Type.String()),
stationName: Type.Optional(Type.String())
}),
notes: Type.String({ maxLength: 500 })
}))
};
export const fuelLogParamsSchema = Type.Object({
id: Type.String({ format: 'uuid' })
});
export const vehicleParamsSchema = Type.Object({
vehicleId: Type.String({ format: 'uuid' })
});
export const fuelTypeParamsSchema = Type.Object({
fuelType: Type.Enum(FuelType)
});
```
## Repository Layer Updates
### Enhanced Repository
**File**: `backend/src/features/fuel-logs/data/fuel-logs.repository.ts` (Updated)
```typescript
import { Pool } from 'pg';
import { FuelLog, CreateFuelLogData } from '../domain/fuel-logs.types';
export interface CreateFuelLogData {
userId: string;
vehicleId: string;
dateTime: Date;
odometerReading?: number;
tripDistance?: number;
fuelType: string;
fuelGrade?: string;
fuelUnits: number;
costPerUnit: number;
totalCost: number;
locationData?: any;
notes?: string;
mpg?: number;
efficiencyCalculationMethod?: string;
}
export class FuelLogsRepository {
constructor(private pool: Pool) {}
async create(data: CreateFuelLogData): Promise<FuelLog> {
const query = `
INSERT INTO fuel_logs (
user_id, vehicle_id, date_time, odometer_reading, trip_distance,
fuel_type, fuel_grade, fuel_units, cost_per_unit, total_cost,
location_data, notes, mpg, efficiency_calculation_method,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), NOW()
) RETURNING *
`;
const values = [
data.userId,
data.vehicleId,
data.dateTime,
data.odometerReading || null,
data.tripDistance || null,
data.fuelType,
data.fuelGrade || null,
data.fuelUnits,
data.costPerUnit,
data.totalCost,
data.locationData ? JSON.stringify(data.locationData) : null,
data.notes || null,
data.mpg || null,
data.efficiencyCalculationMethod || null
];
const result = await this.pool.query(query, values);
return this.mapRowToFuelLog(result.rows[0]);
}
async getPreviousLogByOdometer(vehicleId: string, currentOdometer: number): Promise<FuelLog | null> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
AND odometer_reading IS NOT NULL
AND odometer_reading < $2
ORDER BY odometer_reading DESC, date_time DESC
LIMIT 1
`;
const result = await this.pool.query(query, [vehicleId, currentOdometer]);
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
}
async getLatestLogForVehicle(vehicleId: string): Promise<FuelLog | null> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
ORDER BY date_time DESC, created_at DESC
LIMIT 1
`;
const result = await this.pool.query(query, [vehicleId]);
return result.rows.length > 0 ? this.mapRowToFuelLog(result.rows[0]) : null;
}
async findByVehicleId(vehicleId: string): Promise<FuelLog[]> {
const query = `
SELECT * FROM fuel_logs
WHERE vehicle_id = $1
ORDER BY date_time DESC, created_at DESC
`;
const result = await this.pool.query(query, [vehicleId]);
return result.rows.map(row => this.mapRowToFuelLog(row));
}
private mapRowToFuelLog(row: any): FuelLog {
return {
id: row.id,
userId: row.user_id,
vehicleId: row.vehicle_id,
dateTime: row.date_time,
odometerReading: row.odometer_reading,
tripDistance: row.trip_distance,
fuelType: row.fuel_type,
fuelGrade: row.fuel_grade,
fuelUnits: parseFloat(row.fuel_units),
costPerUnit: parseFloat(row.cost_per_unit),
totalCost: parseFloat(row.total_cost),
locationData: row.location_data ? JSON.parse(row.location_data) : null,
notes: row.notes,
mpg: row.mpg ? parseFloat(row.mpg) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
// Legacy field mapping
date: row.date_time,
odometer: row.odometer_reading,
gallons: parseFloat(row.fuel_units), // Assuming stored in user's preferred units
pricePerGallon: parseFloat(row.cost_per_unit)
};
}
}
```
## Comprehensive Test Suite
### Service Layer Tests
**File**: `backend/src/features/fuel-logs/tests/unit/enhanced-fuel-logs.service.test.ts`
```typescript
import { FuelLogsService } from '../../domain/fuel-logs.service';
import { FuelLogsRepository } from '../../data/fuel-logs.repository';
import { FuelType, UnitSystem } from '../../domain/fuel-logs.types';
import { UserSettingsService } from '../../external/user-settings.service';
// Mock dependencies
jest.mock('../../data/fuel-logs.repository');
jest.mock('../../external/user-settings.service');
jest.mock('../../../core/config/database');
jest.mock('../../../core/config/redis');
describe('Enhanced FuelLogsService', () => {
let service: FuelLogsService;
let mockRepository: jest.Mocked<FuelLogsRepository>;
beforeEach(() => {
mockRepository = new FuelLogsRepository({} as any) as jest.Mocked<FuelLogsRepository>;
service = new FuelLogsService(mockRepository);
// Mock user settings
(UserSettingsService.getUserSettings as jest.Mock).mockResolvedValue({
unitSystem: UnitSystem.IMPERIAL,
currencyCode: 'USD',
timeZone: 'America/New_York'
});
});
describe('createFuelLog', () => {
it('should create fuel log with trip distance', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50,
notes: 'Test fuel log'
};
// Mock vehicle check
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ id: 'vehicle-id' }] }) // Vehicle exists
.mockResolvedValueOnce({}); // Odometer update (not applicable for trip distance)
mockRepository.create.mockResolvedValue({
id: 'fuel-log-id',
userId: 'user-id',
...createData,
totalCost: 35.0,
mpg: 30,
createdAt: new Date(),
updatedAt: new Date()
} as any);
const result = await service.createFuelLog(createData, 'user-id');
expect(result.id).toBe('fuel-log-id');
expect(result.totalCost).toBe(35.0);
expect(result.efficiency).toBe(30);
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
tripDistance: 300,
totalCost: 35.0
})
);
});
it('should validate distance requirement', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50
// Missing both tripDistance and odometerReading
};
await expect(service.createFuelLog(createData, 'user-id'))
.rejects.toThrow('Either odometer reading or trip distance is required');
});
it('should validate fuel grade for fuel type', async () => {
const createData = {
vehicleId: 'vehicle-id',
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '#1', // Invalid for gasoline
fuelUnits: 10,
costPerUnit: 3.50
};
await expect(service.createFuelLog(createData, 'user-id'))
.rejects.toThrow('Invalid fuel grade');
});
});
describe('getEnhancedVehicleStats', () => {
it('should calculate comprehensive vehicle statistics', async () => {
const mockLogs = [
{
fuelUnits: 10,
totalCost: 35,
tripDistance: 300,
mpg: 30,
fuelType: FuelType.GASOLINE,
dateTime: new Date('2024-01-15')
},
{
fuelUnits: 12,
totalCost: 42,
tripDistance: 350,
mpg: 29,
fuelType: FuelType.GASOLINE,
dateTime: new Date('2024-01-10')
}
];
// Mock vehicle check
(pool.query as jest.Mock).mockResolvedValue({ rows: [{ id: 'vehicle-id' }] });
mockRepository.findByVehicleId.mockResolvedValue(mockLogs as any);
const stats = await service.getEnhancedVehicleStats('vehicle-id', 'user-id');
expect(stats.logCount).toBe(2);
expect(stats.totalFuelUnits).toBe(22);
expect(stats.totalCost).toBe(77);
expect(stats.averageCostPerUnit).toBeCloseTo(3.5, 2);
expect(stats.totalDistance).toBe(650);
expect(stats.averageEfficiency).toBeCloseTo(29.5, 1);
});
});
});
```
### Integration Tests
**File**: `backend/src/features/fuel-logs/tests/integration/enhanced-fuel-logs.integration.test.ts`
```typescript
import request from 'supertest';
import { app } from '../../../app';
import { pool } from '../../../core/config/database';
import { FuelType } from '../../domain/fuel-logs.types';
describe('Enhanced Fuel Logs API Integration', () => {
let authToken: string;
let vehicleId: string;
beforeAll(async () => {
// Setup test data
authToken = await getTestAuthToken();
vehicleId = await createTestVehicle();
});
afterAll(async () => {
// Cleanup
await cleanupTestData();
await pool.end();
});
describe('POST /api/fuel-logs', () => {
it('should create fuel log with enhanced fields', async () => {
const fuelLogData = {
vehicleId,
dateTime: '2024-01-15T10:30:00Z',
tripDistance: 300,
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50,
locationData: {
address: '123 Main St, Anytown, USA',
stationName: 'Shell Station'
},
notes: 'Full tank'
};
const response = await request(app)
.post('/api/fuel-logs')
.set('Authorization', `Bearer ${authToken}`)
.send(fuelLogData)
.expect(201);
expect(response.body.id).toBeDefined();
expect(response.body.tripDistance).toBe(300);
expect(response.body.fuelType).toBe(FuelType.GASOLINE);
expect(response.body.fuelGrade).toBe('87');
expect(response.body.totalCost).toBe(35.0);
expect(response.body.efficiency).toBe(30); // 300 miles / 10 gallons
expect(response.body.efficiencyLabel).toBe('mpg');
});
it('should validate distance requirement', async () => {
const fuelLogData = {
vehicleId,
dateTime: '2024-01-15T10:30:00Z',
fuelType: FuelType.GASOLINE,
fuelGrade: '87',
fuelUnits: 10,
costPerUnit: 3.50
// Missing both tripDistance and odometerReading
};
const response = await request(app)
.post('/api/fuel-logs')
.set('Authorization', `Bearer ${authToken}`)
.send(fuelLogData)
.expect(400);
expect(response.body.message).toContain('Either odometer reading or trip distance is required');
});
});
describe('GET /api/fuel-logs/fuel-grades/:fuelType', () => {
it('should return gasoline fuel grades', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-grades/gasoline')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelType).toBe('gasoline');
expect(response.body.grades).toHaveLength(5);
expect(response.body.grades[0]).toEqual({
value: '87',
label: '87 (Regular)',
description: 'Regular unleaded gasoline'
});
});
it('should return empty grades for electric', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-grades/electric')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelType).toBe('electric');
expect(response.body.grades).toHaveLength(0);
});
});
describe('GET /api/fuel-logs/fuel-types', () => {
it('should return all fuel types with grades', async () => {
const response = await request(app)
.get('/api/fuel-logs/fuel-types')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fuelTypes).toHaveLength(3);
const gasoline = response.body.fuelTypes.find(ft => ft.value === 'gasoline');
expect(gasoline.grades).toHaveLength(5);
const electric = response.body.fuelTypes.find(ft => ft.value === 'electric');
expect(electric.grades).toHaveLength(0);
});
});
});
```
## Implementation Tasks
### Service Layer Updates
1. ✅ Update FuelLogsService with enhanced business logic
2. ✅ Integrate validation and efficiency calculation services
3. ✅ Add user settings integration
4. ✅ Implement comprehensive stats calculations
### API Layer Updates
1. ✅ Create FuelGradeController for dynamic grades
2. ✅ Update existing controllers with enhanced validation
3. ✅ Add new API endpoints for fuel types/grades
4. ✅ Update validation schemas
### Repository Updates
1. ✅ Update repository for new database fields
2. ✅ Add methods for enhanced queries
3. ✅ Implement proper data mapping
### Testing Implementation
1. ✅ Create comprehensive unit test suite
2. ✅ Implement integration tests for all endpoints
3. ✅ Add validation testing
4. ✅ Test business logic edge cases
## Success Criteria
### Phase 3 Complete When:
- ✅ All API endpoints functional with enhanced data
- ✅ Comprehensive validation working correctly
- ✅ Fuel type/grade system fully operational
- ✅ Unit conversion integration functional
- ✅ Enhanced statistics calculations working
- ✅ Complete test suite passes (>90% coverage)
- ✅ All new endpoints documented and tested
- ✅ Backward compatibility maintained
### Ready for Phase 4 When:
- All backend services tested and stable
- API contracts finalized and documented
- Frontend integration points clearly defined
- Enhanced business logic fully functional
---
**Next Phase**: [Phase 4 - Frontend Implementation](FUEL-LOGS-PHASE-4.md)