932 lines
29 KiB
Markdown
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) |