MVP Build

This commit is contained in:
Eric Gullickson
2025-08-09 12:47:15 -05:00
parent 2e8816df7f
commit 8f5117a4e2
92 changed files with 5910 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
# UfuelUlogs Feature Capsule
## Quick Summary (50 tokens)
[AI: Complete feature description, main operations, dependencies, caching strategy]
## API Endpoints
- GET /api/fuel-logs - List all fuel-logs
- GET /api/fuel-logs/:id - Get specific lUfuelUlogs
- POST /api/fuel-logs - Create new lUfuelUlogs
- PUT /api/fuel-logs/:id - Update lUfuelUlogs
- DELETE /api/fuel-logs/:id - Delete lUfuelUlogs
## 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
## Dependencies
- Internal: core/auth, core/cache
- External: [List any external APIs]
- Database: fuel-logs table
## Quick Commands
```bash
# Run feature tests
npm test -- features/fuel-logs
# Run feature migrations
npm run migrate:feature fuel-logs
```

View File

@@ -0,0 +1,186 @@
/**
* @ai-summary HTTP request handlers for fuel logs
*/
import { Request, Response, NextFunction } from 'express';
import { FuelLogsService } from '../domain/fuel-logs.service';
import { validateCreateFuelLog, validateUpdateFuelLog } from './fuel-logs.validators';
import { logger } from '../../../core/logging/logger';
export class FuelLogsController {
constructor(private service: FuelLogsService) {}
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const validation = validateCreateFuelLog(req.body);
if (!validation.success) {
return res.status(400).json({
error: 'Validation failed',
details: validation.error.errors
});
}
const result = await this.service.createFuelLog(validation.data, userId);
res.status(201).json(result);
} catch (error: any) {
logger.error('Error creating fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
listByVehicle = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { vehicleId } = req.params;
const result = await this.service.getFuelLogsByVehicle(vehicleId, userId);
res.json(result);
} catch (error: any) {
logger.error('Error listing fuel logs', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
listAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await this.service.getUserFuelLogs(userId);
res.json(result);
} catch (error: any) {
logger.error('Error listing all fuel logs', { error: error.message });
return next(error);
}
}
get = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
const result = await this.service.getFuelLog(id, userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting fuel log', { error: error.message });
if (error.message === 'Fuel log not found') {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
const validation = validateUpdateFuelLog(req.body);
if (!validation.success) {
return res.status(400).json({
error: 'Validation failed',
details: validation.error.errors
});
}
const result = await this.service.updateFuelLog(id, validation.data, userId);
res.json(result);
} catch (error: any) {
logger.error('Error updating fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { id } = req.params;
await this.service.deleteFuelLog(id, userId);
res.status(204).send();
} catch (error: any) {
logger.error('Error deleting fuel log', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
getStats = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { vehicleId } = req.params;
const result = await this.service.getVehicleStats(vehicleId, userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting fuel stats', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message === 'Unauthorized') {
return res.status(403).json({ error: error.message });
}
return next(error);
}
}
}

View File

@@ -0,0 +1,32 @@
/**
* @ai-summary Route definitions for fuel logs API
*/
import { Router } from 'express';
import { FuelLogsController } from './fuel-logs.controller';
import { FuelLogsService } from '../domain/fuel-logs.service';
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import { authMiddleware } from '../../../core/security/auth.middleware';
import pool from '../../../core/config/database';
export function registerFuelLogsRoutes(): Router {
const router = Router();
// Initialize layers
const repository = new FuelLogsRepository(pool);
const service = new FuelLogsService(repository);
const controller = new FuelLogsController(service);
// Define routes
router.get('/api/fuel-logs', authMiddleware, controller.listAll);
router.get('/api/fuel-logs/:id', authMiddleware, controller.get);
router.post('/api/fuel-logs', authMiddleware, controller.create);
router.put('/api/fuel-logs/:id', authMiddleware, controller.update);
router.delete('/api/fuel-logs/:id', authMiddleware, controller.delete);
// Vehicle-specific routes
router.get('/api/vehicles/:vehicleId/fuel-logs', authMiddleware, controller.listByVehicle);
router.get('/api/vehicles/:vehicleId/fuel-stats', authMiddleware, controller.getStats);
return router;
}

View File

@@ -0,0 +1,38 @@
/**
* @ai-summary Input validation for fuel logs API
*/
import { z } from 'zod';
export const createFuelLogSchema = z.object({
vehicleId: z.string().uuid(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
odometer: z.number().int().positive(),
gallons: z.number().positive(),
pricePerGallon: z.number().positive(),
totalCost: z.number().positive(),
station: z.string().max(200).optional(),
location: z.string().max(200).optional(),
notes: z.string().max(1000).optional(),
});
export const updateFuelLogSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
odometer: z.number().int().positive().optional(),
gallons: z.number().positive().optional(),
pricePerGallon: z.number().positive().optional(),
totalCost: z.number().positive().optional(),
station: z.string().max(200).optional(),
location: z.string().max(200).optional(),
notes: z.string().max(1000).optional(),
}).refine(data => Object.keys(data).length > 0, {
message: 'At least one field must be provided for update'
});
export function validateCreateFuelLog(data: unknown) {
return createFuelLogSchema.safeParse(data);
}
export function validateUpdateFuelLog(data: unknown) {
return updateFuelLogSchema.safeParse(data);
}

View File

@@ -0,0 +1,249 @@
/**
* @ai-summary Business logic for fuel logs feature
* @ai-context Handles MPG calculations and vehicle validation
*/
import { FuelLogsRepository } from '../data/fuel-logs.repository';
import {
FuelLog,
CreateFuelLogRequest,
UpdateFuelLogRequest,
FuelLogResponse,
FuelStats
} from './fuel-logs.types';
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 fuel log', { userId, vehicleId: data.vehicleId });
// 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 MPG based on previous log
let mpg: number | undefined;
const previousLog = await this.repository.getPreviousLog(
data.vehicleId,
data.date,
data.odometer
);
if (previousLog && previousLog.odometer < data.odometer) {
const milesDriven = data.odometer - previousLog.odometer;
mpg = milesDriven / data.gallons;
}
// Create fuel log
const fuelLog = await this.repository.create({
...data,
userId,
mpg
});
// Update vehicle odometer
await pool.query(
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND odometer_reading < $1',
[data.odometer, data.vehicleId]
);
// Invalidate caches
await this.invalidateCaches(userId, data.vehicleId);
return this.toResponse(fuelLog);
}
async getFuelLogsByVehicle(vehicleId: string, userId: string): 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');
}
const cacheKey = `${this.cachePrefix}:vehicle:${vehicleId}`;
// 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 => this.toResponse(log));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getUserFuelLogs(userId: string): Promise<FuelLogResponse[]> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
// Check cache
const cached = await cacheService.get<FuelLogResponse[]>(cacheKey);
if (cached) {
return cached;
}
// Get from database
const logs = await this.repository.findByUserId(userId);
const response = logs.map(log => this.toResponse(log));
// Cache result
await cacheService.set(cacheKey, response, this.cacheTTL);
return response;
}
async getFuelLog(id: string, userId: string): Promise<FuelLogResponse> {
const log = await this.repository.findById(id);
if (!log) {
throw new Error('Fuel log not found');
}
if (log.userId !== userId) {
throw new Error('Unauthorized');
}
return this.toResponse(log);
}
async updateFuelLog(
id: string,
data: UpdateFuelLogRequest,
userId: string
): Promise<FuelLogResponse> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Fuel log not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
// Recalculate MPG if odometer or gallons changed
let mpg = existing.mpg;
if (data.odometer || data.gallons) {
const previousLog = await this.repository.getPreviousLog(
existing.vehicleId,
data.date || existing.date.toISOString(),
data.odometer || existing.odometer
);
if (previousLog) {
const odometer = data.odometer || existing.odometer;
const gallons = data.gallons || existing.gallons;
const milesDriven = odometer - previousLog.odometer;
mpg = milesDriven / gallons;
}
}
// Prepare update data with proper types
const updateData: Partial<FuelLog> = {
...data,
date: data.date ? new Date(data.date) : undefined,
mpg
};
// Update
const updated = await this.repository.update(id, updateData);
if (!updated) {
throw new Error('Update failed');
}
// Invalidate caches
await this.invalidateCaches(userId, existing.vehicleId);
return this.toResponse(updated);
}
async deleteFuelLog(id: string, userId: string): Promise<void> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Fuel log not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
await this.repository.delete(id);
// Invalidate caches
await this.invalidateCaches(userId, existing.vehicleId);
}
async getVehicleStats(vehicleId: string, userId: string): Promise<FuelStats> {
// 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 stats = await this.repository.getStats(vehicleId);
if (!stats) {
return {
logCount: 0,
totalGallons: 0,
totalCost: 0,
averagePricePerGallon: 0,
averageMPG: 0,
totalMiles: 0,
};
}
return stats;
}
private async invalidateCaches(userId: string, vehicleId: string): Promise<void> {
await Promise.all([
cacheService.del(`${this.cachePrefix}:user:${userId}`),
cacheService.del(`${this.cachePrefix}:vehicle:${vehicleId}`)
]);
}
private toResponse(log: FuelLog): FuelLogResponse {
return {
id: log.id,
userId: log.userId,
vehicleId: log.vehicleId,
date: log.date.toISOString().split('T')[0],
odometer: log.odometer,
gallons: log.gallons,
pricePerGallon: log.pricePerGallon,
totalCost: log.totalCost,
station: log.station,
location: log.location,
notes: log.notes,
mpg: log.mpg,
createdAt: log.createdAt.toISOString(),
updatedAt: log.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,70 @@
/**
* @ai-summary Type definitions for fuel logs feature
* @ai-context Tracks fuel purchases and calculates MPG
*/
export interface FuelLog {
id: string;
userId: string;
vehicleId: string;
date: Date;
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
mpg?: number; // Calculated field
createdAt: Date;
updatedAt: Date;
}
export interface CreateFuelLogRequest {
vehicleId: string;
date: string; // ISO date string
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
}
export interface UpdateFuelLogRequest {
date?: string;
odometer?: number;
gallons?: number;
pricePerGallon?: number;
totalCost?: number;
station?: string;
location?: string;
notes?: string;
}
export interface FuelLogResponse {
id: string;
userId: string;
vehicleId: string;
date: string;
odometer: number;
gallons: number;
pricePerGallon: number;
totalCost: number;
station?: string;
location?: string;
notes?: string;
mpg?: number;
createdAt: string;
updatedAt: string;
}
export interface FuelStats {
totalGallons: number;
totalCost: number;
averagePricePerGallon: number;
averageMPG: number;
totalMiles: number;
logCount: number;
}

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Public API for fuel-logs feature capsule
*/
// Export service for use by other features
export { FuelLogsService } from './domain/fuel-logs.service';
// Export types
export type {
FuelLog,
CreateFuelLogRequest,
UpdateFuelLogRequest,
FuelLogResponse,
FuelStats
} from './domain/fuel-logs.types';
// Internal: Register routes
export { registerFuelLogsRoutes } from './api/fuel-logs.routes';

View File

@@ -0,0 +1,34 @@
-- Create fuel_logs table
CREATE TABLE IF NOT EXISTS fuel_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
date DATE NOT NULL,
odometer INTEGER NOT NULL,
gallons DECIMAL(10, 3) NOT NULL,
price_per_gallon DECIMAL(10, 3) NOT NULL,
total_cost DECIMAL(10, 2) NOT NULL,
station VARCHAR(200),
location VARCHAR(200),
notes TEXT,
mpg DECIMAL(10, 2), -- Calculated based on previous log
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_fuel_logs_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE
);
-- Create indexes
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
CREATE INDEX idx_fuel_logs_vehicle_id ON fuel_logs(vehicle_id);
CREATE INDEX idx_fuel_logs_date ON fuel_logs(date DESC);
CREATE INDEX idx_fuel_logs_created_at ON fuel_logs(created_at DESC);
-- Add trigger for updated_at
CREATE TRIGGER update_fuel_logs_updated_at
BEFORE UPDATE ON fuel_logs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,35 @@
# Umaintenance Feature Capsule
## Quick Summary (50 tokens)
[AI: Complete feature description, main operations, dependencies, caching strategy]
## API Endpoints
- GET /api/maintenance - List all maintenance
- GET /api/maintenance/:id - Get specific lUmaintenance
- POST /api/maintenance - Create new lUmaintenance
- PUT /api/maintenance/:id - Update lUmaintenance
- DELETE /api/maintenance/:id - Delete lUmaintenance
## 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
## Dependencies
- Internal: core/auth, core/cache
- External: [List any external APIs]
- Database: maintenance table
## Quick Commands
```bash
# Run feature tests
npm test -- features/maintenance
# Run feature migrations
npm run migrate:feature maintenance
```

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Public API for maintenance feature capsule
* @ai-note This is the ONLY file other features should import from
*/
// Export service for use by other features
export { UmaintenanceService } from './domain/lUmaintenance.service';
// Export types needed by other features
export type {
Umaintenance,
CreateUmaintenanceRequest,
UpdateUmaintenanceRequest,
UmaintenanceResponse
} from './domain/lUmaintenance.types';
// Internal: Register routes with Express app
export { registerUmaintenanceRoutes } from './api/lUmaintenance.routes';

View File

@@ -0,0 +1,66 @@
-- Create maintenance_logs table
CREATE TABLE IF NOT EXISTS maintenance_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vehicle_id UUID NOT NULL,
date DATE NOT NULL,
odometer INTEGER NOT NULL,
type VARCHAR(100) NOT NULL, -- oil_change, tire_rotation, etc.
description TEXT,
cost DECIMAL(10, 2),
shop_name VARCHAR(200),
notes TEXT,
next_due_date DATE,
next_due_mileage INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_maintenance_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE
);
-- Create maintenance_schedules table
CREATE TABLE IF NOT EXISTS maintenance_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
vehicle_id UUID NOT NULL,
type VARCHAR(100) NOT NULL,
interval_months INTEGER,
interval_miles INTEGER,
last_performed_date DATE,
last_performed_mileage INTEGER,
next_due_date DATE,
next_due_mileage INTEGER,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_schedule_vehicle
FOREIGN KEY (vehicle_id)
REFERENCES vehicles(id)
ON DELETE CASCADE,
CONSTRAINT unique_vehicle_maintenance_type
UNIQUE(vehicle_id, type)
);
-- Create indexes
CREATE INDEX idx_maintenance_logs_user_id ON maintenance_logs(user_id);
CREATE INDEX idx_maintenance_logs_vehicle_id ON maintenance_logs(vehicle_id);
CREATE INDEX idx_maintenance_logs_date ON maintenance_logs(date DESC);
CREATE INDEX idx_maintenance_logs_type ON maintenance_logs(type);
CREATE INDEX idx_maintenance_schedules_vehicle_id ON maintenance_schedules(vehicle_id);
CREATE INDEX idx_maintenance_schedules_next_due_date ON maintenance_schedules(next_due_date);
-- Add triggers
CREATE TRIGGER update_maintenance_logs_updated_at
BEFORE UPDATE ON maintenance_logs
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_maintenance_schedules_updated_at
BEFORE UPDATE ON maintenance_schedules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,35 @@
# Ustations Feature Capsule
## Quick Summary (50 tokens)
[AI: Complete feature description, main operations, dependencies, caching strategy]
## API Endpoints
- GET /api/stations - List all stations
- GET /api/stations/:id - Get specific lUstations
- POST /api/stations - Create new lUstations
- PUT /api/stations/:id - Update lUstations
- DELETE /api/stations/:id - Delete lUstations
## 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
## Dependencies
- Internal: core/auth, core/cache
- External: [List any external APIs]
- Database: stations table
## Quick Commands
```bash
# Run feature tests
npm test -- features/stations
# Run feature migrations
npm run migrate:feature stations
```

View File

@@ -0,0 +1,105 @@
/**
* @ai-summary HTTP request handlers for stations
*/
import { Request, Response, NextFunction } from 'express';
import { StationsService } from '../domain/stations.service';
import { logger } from '../../../core/logging/logger';
export class StationsController {
constructor(private service: StationsService) {}
search = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { latitude, longitude, radius, fuelType } = req.body;
if (!latitude || !longitude) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
}
const result = await this.service.searchNearbyStations({
latitude,
longitude,
radius,
fuelType
}, userId);
res.json(result);
} catch (error: any) {
logger.error('Error searching stations', { error: error.message });
return next(error);
}
}
save = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { placeId, nickname, notes, isFavorite } = req.body;
if (!placeId) {
return res.status(400).json({ error: 'Place ID is required' });
}
const result = await this.service.saveStation(placeId, userId, {
nickname,
notes,
isFavorite
});
res.status(201).json(result);
} catch (error: any) {
logger.error('Error saving station', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
return next(error);
}
}
getSaved = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await this.service.getUserSavedStations(userId);
res.json(result);
} catch (error: any) {
logger.error('Error getting saved stations', { error: error.message });
return next(error);
}
}
removeSaved = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.user?.sub;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { placeId } = req.params;
await this.service.removeSavedStation(placeId, userId);
res.status(204).send();
} catch (error: any) {
logger.error('Error removing saved station', { error: error.message });
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
return next(error);
}
}
}

View File

@@ -0,0 +1,27 @@
/**
* @ai-summary Route definitions for stations API
*/
import { Router } from 'express';
import { StationsController } from './stations.controller';
import { StationsService } from '../domain/stations.service';
import { StationsRepository } from '../data/stations.repository';
import { authMiddleware } from '../../../core/security/auth.middleware';
import pool from '../../../core/config/database';
export function registerStationsRoutes(): Router {
const router = Router();
// Initialize layers
const repository = new StationsRepository(pool);
const service = new StationsService(repository);
const controller = new StationsController(service);
// Define routes
router.post('/api/stations/search', authMiddleware, controller.search);
router.post('/api/stations/save', authMiddleware, controller.save);
router.get('/api/stations/saved', authMiddleware, controller.getSaved);
router.delete('/api/stations/saved/:placeId', authMiddleware, controller.removeSaved);
return router;
}

View File

@@ -0,0 +1,90 @@
/**
* @ai-summary Business logic for stations feature
*/
import { StationsRepository } from '../data/stations.repository';
import { googleMapsClient } from '../external/google-maps/google-maps.client';
import { StationSearchRequest, StationSearchResponse } from './stations.types';
import { logger } from '../../../core/logging/logger';
export class StationsService {
constructor(private repository: StationsRepository) {}
async searchNearbyStations(
request: StationSearchRequest,
userId: string
): Promise<StationSearchResponse> {
logger.info('Searching for stations', { userId, ...request });
// Search via Google Maps
const stations = await googleMapsClient.searchNearbyStations(
request.latitude,
request.longitude,
request.radius || 5000
);
// Cache stations for future reference
for (const station of stations) {
await this.repository.cacheStation(station);
}
// Sort by distance
stations.sort((a, b) => (a.distance || 0) - (b.distance || 0));
return {
stations,
searchLocation: {
latitude: request.latitude,
longitude: request.longitude
},
searchRadius: request.radius || 5000,
timestamp: new Date().toISOString()
};
}
async saveStation(
placeId: string,
userId: string,
data?: { nickname?: string; notes?: string; isFavorite?: boolean }
) {
// Get station details from cache
const station = await this.repository.getCachedStation(placeId);
if (!station) {
throw new Error('Station not found. Please search for stations first.');
}
// Save to user's saved stations
const saved = await this.repository.saveStation(userId, placeId, data);
return {
...saved,
station
};
}
async getUserSavedStations(userId: string) {
const savedStations = await this.repository.getUserSavedStations(userId);
// Enrich with cached station data
const enriched = await Promise.all(
savedStations.map(async (saved) => {
const station = await this.repository.getCachedStation(saved.stationId);
return {
...saved,
station
};
})
);
return enriched;
}
async removeSavedStation(placeId: string, userId: string) {
const removed = await this.repository.deleteSavedStation(userId, placeId);
if (!removed) {
throw new Error('Saved station not found');
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* @ai-summary Type definitions for stations feature
* @ai-context Gas station discovery and caching
*/
export interface Station {
id: string;
placeId: string; // Google Places ID
name: string;
address: string;
latitude: number;
longitude: number;
priceRegular?: number;
pricePremium?: number;
priceDiesel?: number;
lastUpdated?: Date;
distance?: number; // Distance from search point in meters
isOpen?: boolean;
rating?: number;
photoUrl?: string;
}
export interface StationSearchRequest {
latitude: number;
longitude: number;
radius?: number; // Radius in meters (default 5000)
fuelType?: 'regular' | 'premium' | 'diesel';
}
export interface StationSearchResponse {
stations: Station[];
searchLocation: {
latitude: number;
longitude: number;
};
searchRadius: number;
timestamp: string;
}
export interface SavedStation {
id: string;
userId: string;
stationId: string;
nickname?: string;
notes?: string;
isFavorite: boolean;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,112 @@
/**
* @ai-summary Google Maps client for station discovery
* @ai-context Searches for gas stations and caches results
*/
import axios from 'axios';
import { env } from '../../../../core/config/environment';
import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis';
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
import { Station } from '../../domain/stations.types';
export class GoogleMapsClient {
private readonly apiKey = env.GOOGLE_MAPS_API_KEY;
private readonly baseURL = 'https://maps.googleapis.com/maps/api/place';
private readonly cacheTTL = 3600; // 1 hour
async searchNearbyStations(
latitude: number,
longitude: number,
radius: number = 5000
): Promise<Station[]> {
const cacheKey = `stations:${latitude.toFixed(4)},${longitude.toFixed(4)},${radius}`;
try {
// Check cache
const cached = await cacheService.get<Station[]>(cacheKey);
if (cached) {
logger.debug('Station search cache hit', { latitude, longitude });
return cached;
}
// Search Google Places
logger.info('Searching Google Places for stations', { latitude, longitude, radius });
const response = await axios.get<GooglePlacesResponse>(
`${this.baseURL}/nearbysearch/json`,
{
params: {
location: `${latitude},${longitude}`,
radius,
type: 'gas_station',
key: this.apiKey
}
}
);
if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') {
throw new Error(`Google Places API error: ${response.data.status}`);
}
// Transform results
const stations = response.data.results.map(place =>
this.transformPlaceToStation(place, latitude, longitude)
);
// Cache results
await cacheService.set(cacheKey, stations, this.cacheTTL);
return stations;
} catch (error) {
logger.error('Station search failed', { error, latitude, longitude });
throw error;
}
}
private transformPlaceToStation(place: GooglePlace, searchLat: number, searchLng: number): Station {
// Calculate distance from search point
const distance = this.calculateDistance(
searchLat,
searchLng,
place.geometry.location.lat,
place.geometry.location.lng
);
// Generate photo URL if available
let photoUrl: string | undefined;
if (place.photos && place.photos.length > 0) {
photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photo_reference=${place.photos[0].photo_reference}&key=${this.apiKey}`;
}
return {
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
};
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3; // Earth's radius in meters
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δφ = (lat2 - lat1) * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return Math.round(R * c); // Distance in meters
}
}
export const googleMapsClient = new GoogleMapsClient();

View File

@@ -0,0 +1,55 @@
/**
* @ai-summary Google Maps API types
*/
export interface GooglePlacesResponse {
results: GooglePlace[];
status: string;
next_page_token?: string;
}
export interface GooglePlace {
place_id: string;
name: string;
vicinity: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
opening_hours?: {
open_now: boolean;
};
rating?: number;
photos?: Array<{
photo_reference: string;
}>;
price_level?: number;
types: string[];
}
export interface GooglePlaceDetails {
result: {
place_id: string;
name: string;
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
opening_hours?: {
open_now: boolean;
weekday_text: string[];
};
rating?: number;
photos?: Array<{
photo_reference: string;
}>;
formatted_phone_number?: string;
website?: string;
};
status: string;
}

View File

@@ -0,0 +1,17 @@
/**
* @ai-summary Public API for stations feature capsule
*/
// Export service
export { StationsService } from './domain/stations.service';
// Export types
export type {
Station,
StationSearchRequest,
StationSearchResponse,
SavedStation
} from './domain/stations.types';
// Internal: Register routes
export { registerStationsRoutes } from './api/stations.routes';

View File

@@ -0,0 +1,44 @@
-- Create station cache table
CREATE TABLE IF NOT EXISTS station_cache (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
place_id VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
price_regular DECIMAL(10, 2),
price_premium DECIMAL(10, 2),
price_diesel DECIMAL(10, 2),
rating DECIMAL(2, 1),
photo_url TEXT,
raw_data JSONB,
cached_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create saved stations table for user favorites
CREATE TABLE IF NOT EXISTS saved_stations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
place_id VARCHAR(255) NOT NULL,
nickname VARCHAR(100),
notes TEXT,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_user_station UNIQUE(user_id, place_id)
);
-- Create indexes
CREATE INDEX idx_station_cache_place_id ON station_cache(place_id);
CREATE INDEX idx_station_cache_location ON station_cache(latitude, longitude);
CREATE INDEX idx_station_cache_cached_at ON station_cache(cached_at);
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
CREATE INDEX idx_saved_stations_is_favorite ON saved_stations(is_favorite);
-- Add trigger for updated_at
CREATE TRIGGER update_saved_stations_updated_at
BEFORE UPDATE ON saved_stations
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,208 @@
# Vehicles Feature Capsule
## Quick Summary (50 tokens)
Primary entity for vehicle management with VIN decoding via NHTSA vPIC API. Handles CRUD operations, automatic vehicle data population, user ownership validation, caching strategy (VIN lookups: 30 days, user lists: 5 minutes). Foundation for fuel-logs and maintenance features.
## API Endpoints
- `POST /api/vehicles` - Create new vehicle with VIN decoding
- `GET /api/vehicles` - List all user's vehicles (cached 5 min)
- `GET /api/vehicles/:id` - Get specific vehicle
- `PUT /api/vehicles/:id` - Update vehicle details
- `DELETE /api/vehicles/:id` - Soft delete vehicle
## Authentication Required
All endpoints require valid JWT token with user context.
## Request/Response Examples
### Create Vehicle
```json
POST /api/vehicles
{
"vin": "1HGBH41JXMN109186",
"nickname": "My Honda",
"color": "Blue",
"licensePlate": "ABC123",
"odometerReading": 50000
}
Response (201):
{
"id": "uuid-here",
"userId": "user-id",
"vin": "1HGBH41JXMN109186",
"make": "Honda", // Auto-decoded
"model": "Civic", // Auto-decoded
"year": 2021, // Auto-decoded
"nickname": "My Honda",
"color": "Blue",
"licensePlate": "ABC123",
"odometerReading": 50000,
"isActive": true,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
```
## Feature Architecture
### Complete Self-Contained Structure
```
vehicles/
├── README.md # This file
├── index.ts # Public API exports
├── api/ # HTTP layer
│ ├── vehicles.controller.ts
│ ├── vehicles.routes.ts
│ └── vehicles.validation.ts
├── domain/ # Business logic
│ ├── vehicles.service.ts
│ └── vehicles.types.ts
├── data/ # Database layer
│ └── vehicles.repository.ts
├── migrations/ # Feature schema
│ └── 001_create_vehicles_tables.sql
├── external/ # External APIs
│ └── vpic/
│ ├── vpic.client.ts
│ └── vpic.types.ts
├── tests/ # All tests
│ ├── unit/
│ │ ├── vehicles.service.test.ts
│ │ └── vpic.client.test.ts
│ └── integration/
│ └── vehicles.integration.test.ts
└── docs/ # Additional docs
```
## Key Features
### 🔍 Automatic VIN Decoding
- **External API**: NHTSA vPIC (Vehicle Product Information Catalog)
- **Caching**: 30-day Redis cache for VIN lookups
- **Fallback**: Graceful handling of decode failures
- **Validation**: 17-character VIN format validation
### 🏗️ Database Schema
- **Primary Table**: `vehicles` with soft delete
- **Cache Table**: `vin_cache` for external API results
- **Indexes**: Optimized for user queries and VIN lookups
- **Constraints**: Unique VIN per user, proper foreign keys
### 🚀 Performance Optimizations
- **Redis Caching**: User vehicle lists cached for 5 minutes
- **VIN Cache**: 30-day persistent cache in PostgreSQL
- **Indexes**: Strategic database indexes for fast queries
- **Soft Deletes**: Maintains referential integrity
## Business Rules
### VIN Validation
- Must be exactly 17 characters
- Cannot contain letters I, O, or Q
- Must pass basic checksum validation
- Auto-populates make, model, year from vPIC API
### User Ownership
- Each user can have multiple vehicles
- Same VIN cannot be registered twice by same user
- All operations validate user ownership
- Soft delete preserves data for audit trail
## Dependencies
### Internal Core Services
- `core/auth` - JWT authentication middleware
- `core/config` - Database pool, Redis cache
- `core/logging` - Structured logging with Winston
- `shared-minimal/utils` - Pure validation utilities
### External Services
- **NHTSA vPIC API** - VIN decoding service
- **PostgreSQL** - Primary data storage
- **Redis** - Caching layer
### Database Tables
- `vehicles` - Primary vehicle data
- `vin_cache` - External API response cache
## Caching Strategy
### VIN Decode Cache (30 days)
- **Key**: `vpic:vin:{vin}`
- **TTL**: 2,592,000 seconds (30 days)
- **Rationale**: Vehicle specifications never change
### User Vehicle List (5 minutes)
- **Key**: `vehicles:user:{userId}`
- **TTL**: 300 seconds (5 minutes)
- **Invalidation**: On create, update, delete
## Testing
### Unit Tests
- `vehicles.service.test.ts` - Business logic with mocked dependencies
- `vpic.client.test.ts` - External API client with mocked HTTP
### Integration Tests
- `vehicles.integration.test.ts` - Complete API workflow with test database
### Run Tests
```bash
# All vehicle tests
npm test -- features/vehicles
# Unit tests only
npm test -- features/vehicles/tests/unit
# Integration tests only
npm test -- features/vehicles/tests/integration
# With coverage
npm test -- features/vehicles --coverage
```
## Error Handling
### Client Errors (4xx)
- `400` - Invalid VIN format, validation errors
- `401` - Missing or invalid JWT token
- `403` - User not authorized for vehicle
- `404` - Vehicle not found
- `409` - Duplicate VIN for user
### Server Errors (5xx)
- `500` - Database connection, VIN API failures
- Graceful degradation when vPIC API unavailable
## Future Considerations
### Dependent Features
- **fuel-logs** - Will reference `vehicle_id`
- **maintenance** - Will reference `vehicle_id`
- Both features depend on vehicles as primary entity
### Potential Enhancements
- Vehicle image uploads (MinIO integration)
- VIN decode webhook for real-time updates
- Vehicle value estimation integration
- Maintenance scheduling based on vehicle age/mileage
## Development Commands
```bash
# Run migrations
make migrate
# Start development environment
make dev
# View feature logs
make logs-backend | grep vehicles
# Open container shell
make shell-backend
# Inside container - run feature tests
npm test -- features/vehicles
```

View File

@@ -0,0 +1,164 @@
/**
* @ai-summary HTTP request handlers for vehicles API
* @ai-context Handles validation, auth, and delegates to service layer
*/
import { Request, Response, NextFunction } from 'express';
import { VehiclesService } from '../domain/vehicles.service';
import { VehiclesRepository } from '../data/vehicles.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { ZodError } from 'zod';
import {
createVehicleSchema,
updateVehicleSchema,
vehicleIdSchema,
CreateVehicleInput,
UpdateVehicleInput,
} from './vehicles.validation';
export class VehiclesController {
private service: VehiclesService;
constructor() {
const repository = new VehiclesRepository(pool);
this.service = new VehiclesService(repository);
}
createVehicle = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
// Validate request body
const data = createVehicleSchema.parse(req.body) as CreateVehicleInput;
// Get user ID from JWT token
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const vehicle = await this.service.createVehicle(data, userId);
logger.info('Vehicle created successfully', { vehicleId: vehicle.id, userId });
res.status(201).json(vehicle);
} catch (error: any) {
if (error instanceof ZodError) {
res.status(400).json({ error: error.errors[0].message });
return;
}
if (error.message === 'Invalid VIN format' ||
error.message === 'Vehicle with this VIN already exists') {
res.status(400).json({ error: error.message });
return;
}
next(error);
}
};
getUserVehicles = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const vehicles = await this.service.getUserVehicles(userId);
res.json(vehicles);
} catch (error) {
next(error);
}
};
getVehicle = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { id } = vehicleIdSchema.parse(req.params);
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const vehicle = await this.service.getVehicle(id, userId);
res.json(vehicle);
} catch (error: any) {
if (error instanceof ZodError) {
res.status(400).json({ error: error.errors[0].message });
return;
}
if (error.message === 'Vehicle not found') {
res.status(404).json({ error: 'Vehicle not found' });
return;
}
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Access denied' });
return;
}
next(error);
}
};
updateVehicle = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { id } = vehicleIdSchema.parse(req.params);
const data = updateVehicleSchema.parse(req.body) as UpdateVehicleInput;
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const vehicle = await this.service.updateVehicle(id, data, userId);
logger.info('Vehicle updated successfully', { vehicleId: id, userId });
res.json(vehicle);
} catch (error: any) {
if (error instanceof ZodError) {
res.status(400).json({ error: error.errors[0].message });
return;
}
if (error.message === 'Vehicle not found') {
res.status(404).json({ error: 'Vehicle not found' });
return;
}
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Access denied' });
return;
}
next(error);
}
};
deleteVehicle = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { id } = vehicleIdSchema.parse(req.params);
const userId = req.user?.sub;
if (!userId) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
await this.service.deleteVehicle(id, userId);
logger.info('Vehicle deleted successfully', { vehicleId: id, userId });
res.status(204).send();
} catch (error: any) {
if (error instanceof ZodError) {
res.status(400).json({ error: error.errors[0].message });
return;
}
if (error.message === 'Vehicle not found') {
res.status(404).json({ error: 'Vehicle not found' });
return;
}
if (error.message === 'Unauthorized') {
res.status(403).json({ error: 'Access denied' });
return;
}
next(error);
}
};
}

View File

@@ -0,0 +1,25 @@
/**
* @ai-summary Express routes for vehicles API
* @ai-context Defines REST endpoints with auth middleware
*/
import { Router } from 'express';
import { VehiclesController } from './vehicles.controller';
import { authMiddleware } from '../../../core/security/auth.middleware';
export function registerVehiclesRoutes(): Router {
const router = Router();
const controller = new VehiclesController();
// All vehicle routes require authentication
router.use(authMiddleware);
// Routes
router.post('/api/vehicles', controller.createVehicle);
router.get('/api/vehicles', controller.getUserVehicles);
router.get('/api/vehicles/:id', controller.getVehicle);
router.put('/api/vehicles/:id', controller.updateVehicle);
router.delete('/api/vehicles/:id', controller.deleteVehicle);
return router;
}

View File

@@ -0,0 +1,32 @@
/**
* @ai-summary Request validation schemas for vehicles API
* @ai-context Uses Zod for runtime validation and type safety
*/
import { z } from 'zod';
import { isValidVIN } from '../../../shared-minimal/utils/validators';
export const createVehicleSchema = z.object({
vin: z.string()
.length(17, 'VIN must be exactly 17 characters')
.refine(isValidVIN, 'Invalid VIN format'),
nickname: z.string().min(1).max(100).optional(),
color: z.string().min(1).max(50).optional(),
licensePlate: z.string().min(1).max(20).optional(),
odometerReading: z.number().min(0).max(9999999).optional(),
});
export const updateVehicleSchema = z.object({
nickname: z.string().min(1).max(100).optional(),
color: z.string().min(1).max(50).optional(),
licensePlate: z.string().min(1).max(20).optional(),
odometerReading: z.number().min(0).max(9999999).optional(),
}).strict();
export const vehicleIdSchema = z.object({
id: z.string().uuid('Invalid vehicle ID format'),
});
export type CreateVehicleInput = z.infer<typeof createVehicleSchema>;
export type UpdateVehicleInput = z.infer<typeof updateVehicleSchema>;
export type VehicleIdInput = z.infer<typeof vehicleIdSchema>;

View File

@@ -0,0 +1,160 @@
/**
* @ai-summary Business logic for vehicles feature
* @ai-context Handles VIN decoding, caching, and business rules
*/
import { VehiclesRepository } from '../data/vehicles.repository';
import { vpicClient } from '../external/vpic/vpic.client';
import {
Vehicle,
CreateVehicleRequest,
UpdateVehicleRequest,
VehicleResponse
} from './vehicles.types';
import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis';
import { isValidVIN } from '../../../shared-minimal/utils/validators';
export class VehiclesService {
private readonly cachePrefix = 'vehicles';
private readonly listCacheTTL = 300; // 5 minutes
constructor(private repository: VehiclesRepository) {}
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
logger.info('Creating vehicle', { userId, vin: data.vin });
// Validate VIN
if (!isValidVIN(data.vin)) {
throw new Error('Invalid VIN format');
}
// Check for duplicate
const existing = await this.repository.findByUserAndVIN(userId, data.vin);
if (existing) {
throw new Error('Vehicle with this VIN already exists');
}
// Decode VIN
const vinData = await vpicClient.decodeVIN(data.vin);
// Create vehicle with decoded data
const vehicle = await this.repository.create({
...data,
userId,
make: vinData?.make,
model: vinData?.model,
year: vinData?.year,
});
// Cache VIN decode result
if (vinData) {
await this.repository.cacheVINDecode(data.vin, vinData);
}
// Invalidate user's vehicle list cache
await this.invalidateUserCache(userId);
return this.toResponse(vehicle);
}
async getUserVehicles(userId: string): Promise<VehicleResponse[]> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
// Check cache
const cached = await cacheService.get<VehicleResponse[]>(cacheKey);
if (cached) {
logger.debug('Vehicle list cache hit', { userId });
return cached;
}
// Get from database
const vehicles = await this.repository.findByUserId(userId);
const response = vehicles.map(v => this.toResponse(v));
// Cache result
await cacheService.set(cacheKey, response, this.listCacheTTL);
return response;
}
async getVehicle(id: string, userId: string): Promise<VehicleResponse> {
const vehicle = await this.repository.findById(id);
if (!vehicle) {
throw new Error('Vehicle not found');
}
if (vehicle.userId !== userId) {
throw new Error('Unauthorized');
}
return this.toResponse(vehicle);
}
async updateVehicle(
id: string,
data: UpdateVehicleRequest,
userId: string
): Promise<VehicleResponse> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Vehicle not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
// Update vehicle
const updated = await this.repository.update(id, data);
if (!updated) {
throw new Error('Update failed');
}
// Invalidate cache
await this.invalidateUserCache(userId);
return this.toResponse(updated);
}
async deleteVehicle(id: string, userId: string): Promise<void> {
// Verify ownership
const existing = await this.repository.findById(id);
if (!existing) {
throw new Error('Vehicle not found');
}
if (existing.userId !== userId) {
throw new Error('Unauthorized');
}
// Soft delete
await this.repository.softDelete(id);
// Invalidate cache
await this.invalidateUserCache(userId);
}
private async invalidateUserCache(userId: string): Promise<void> {
const cacheKey = `${this.cachePrefix}:user:${userId}`;
await cacheService.del(cacheKey);
}
private toResponse(vehicle: Vehicle): VehicleResponse {
return {
id: vehicle.id,
userId: vehicle.userId,
vin: vehicle.vin,
make: vehicle.make,
model: vehicle.model,
year: vehicle.year,
nickname: vehicle.nickname,
color: vehicle.color,
licensePlate: vehicle.licensePlate,
odometerReading: vehicle.odometerReading,
isActive: vehicle.isActive,
createdAt: vehicle.createdAt.toISOString(),
updatedAt: vehicle.updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,61 @@
/**
* @ai-summary Type definitions for vehicles feature
* @ai-context Core business types, no external dependencies
*/
export interface Vehicle {
id: string;
userId: string;
vin: string;
make?: string;
model?: string;
year?: number;
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading: number;
isActive: boolean;
deletedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateVehicleRequest {
vin: string;
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading?: number;
}
export interface UpdateVehicleRequest {
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading?: number;
}
export interface VehicleResponse {
id: string;
userId: string;
vin: string;
make?: string;
model?: string;
year?: number;
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface VINDecodeResult {
make: string;
model: string;
year: number;
engineType?: string;
bodyType?: string;
rawData?: any;
}

View File

@@ -0,0 +1,78 @@
/**
* @ai-summary NHTSA vPIC API client for VIN decoding
* @ai-context Caches results for 30 days since vehicle specs don't change
*/
import axios from 'axios';
import { env } from '../../../../core/config/environment';
import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis';
import { VPICResponse, VPICDecodeResult } from './vpic.types';
export class VPICClient {
private readonly baseURL = env.VPIC_API_URL;
private readonly cacheTTL = 30 * 24 * 60 * 60; // 30 days in seconds
async decodeVIN(vin: string): Promise<VPICDecodeResult | null> {
const cacheKey = `vpic:vin:${vin}`;
try {
// Check cache first
const cached = await cacheService.get<VPICDecodeResult>(cacheKey);
if (cached) {
logger.debug('VIN decode cache hit', { vin });
return cached;
}
// Call vPIC API
logger.info('Calling vPIC API', { vin });
const response = await axios.get<VPICResponse>(
`${this.baseURL}/DecodeVin/${vin}?format=json`
);
if (response.data.Count === 0) {
logger.warn('VIN decode returned no results', { vin });
return null;
}
// Parse response
const result = this.parseVPICResponse(response.data);
// Cache successful result
if (result) {
await cacheService.set(cacheKey, result, this.cacheTTL);
}
return result;
} catch (error) {
logger.error('VIN decode failed', { vin, error });
return null;
}
}
private parseVPICResponse(response: VPICResponse): VPICDecodeResult | null {
const getValue = (variable: string): string | undefined => {
const result = response.Results.find(r => r.Variable === variable);
return result?.Value || undefined;
};
const make = getValue('Make');
const model = getValue('Model');
const year = getValue('Model Year');
if (!make || !model || !year) {
return null;
}
return {
make,
model,
year: parseInt(year, 10),
engineType: getValue('Engine Model'),
bodyType: getValue('Body Class'),
rawData: response.Results,
};
}
}
export const vpicClient = new VPICClient();

View File

@@ -0,0 +1,26 @@
/**
* @ai-summary NHTSA vPIC API types
*/
export interface VPICResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: VPICResult[];
}
export interface VPICResult {
Value: string | null;
ValueId: string | null;
Variable: string;
VariableId: number;
}
export interface VPICDecodeResult {
make: string;
model: string;
year: number;
engineType?: string;
bodyType?: string;
rawData: VPICResult[];
}

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Public API for vehicles feature capsule
* @ai-note This is the ONLY file other features should import from
*/
// Export service for use by other features
export { VehiclesService } from './domain/vehicles.service';
// Export types needed by other features
export type {
Vehicle,
CreateVehicleRequest,
UpdateVehicleRequest,
VehicleResponse
} from './domain/vehicles.types';
// Internal: Register routes with Express app
export { registerVehiclesRoutes } from './api/vehicles.routes';

View File

@@ -0,0 +1,58 @@
-- Enable UUID extension if not exists
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create vehicles table
CREATE TABLE IF NOT EXISTS vehicles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
vin VARCHAR(17) NOT NULL,
make VARCHAR(100),
model VARCHAR(100),
year INTEGER,
nickname VARCHAR(100),
color VARCHAR(50),
license_plate VARCHAR(20),
odometer_reading INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_user_vin UNIQUE(user_id, vin)
);
-- Create indexes for performance
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
CREATE INDEX idx_vehicles_vin ON vehicles(vin);
CREATE INDEX idx_vehicles_is_active ON vehicles(is_active);
CREATE INDEX idx_vehicles_created_at ON vehicles(created_at);
-- Create VIN cache table for external API results
CREATE TABLE IF NOT EXISTS vin_cache (
vin VARCHAR(17) PRIMARY KEY,
make VARCHAR(100),
model VARCHAR(100),
year INTEGER,
engine_type VARCHAR(100),
body_type VARCHAR(100),
raw_data JSONB,
cached_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create index on cache timestamp for cleanup
CREATE INDEX idx_vin_cache_cached_at ON vin_cache(cached_at);
-- Create update trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Add trigger to vehicles table
CREATE TRIGGER update_vehicles_updated_at
BEFORE UPDATE ON vehicles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,285 @@
/**
* @ai-summary Integration tests for vehicles API endpoints
* @ai-context Tests complete request/response cycle with test database
*/
import request from 'supertest';
import { app } from '../../../../app';
import { pool } from '../../../../core/config/database';
import { cacheService } from '../../../../core/config/redis';
import { readFileSync } from 'fs';
import { join } from 'path';
// Mock auth middleware to bypass JWT validation in tests
jest.mock('../../../../core/security/auth.middleware', () => ({
authMiddleware: (req: any, _res: any, next: any) => {
req.user = { sub: 'test-user-123' };
next();
}
}));
// Mock external VIN decoder
jest.mock('../../external/vpic/vpic.client', () => ({
vpicClient: {
decodeVIN: jest.fn().mockResolvedValue({
make: 'Honda',
model: 'Civic',
year: 2021,
engineType: '2.0L',
bodyType: 'Sedan',
rawData: []
})
}
}));
describe('Vehicles Integration Tests', () => {
beforeAll(async () => {
// Run the vehicles migration directly using the migration file
const migrationFile = join(__dirname, '../../migrations/001_create_vehicles_tables.sql');
const migrationSQL = readFileSync(migrationFile, 'utf-8');
await pool.query(migrationSQL);
});
afterAll(async () => {
// Clean up test database
await pool.query('DROP TABLE IF EXISTS vehicles CASCADE');
await pool.query('DROP TABLE IF EXISTS vin_cache CASCADE');
await pool.query('DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE');
await pool.end();
});
beforeEach(async () => {
// Clean up test data before each test - more thorough cleanup
await pool.query('DELETE FROM vehicles WHERE user_id = $1', ['test-user-123']);
await pool.query('DELETE FROM vin_cache WHERE vin LIKE $1', ['1HGBH41JXMN%']);
// Clear Redis cache for the test user
try {
await cacheService.del('vehicles:user:test-user-123');
} catch (error) {
// Ignore cache cleanup errors in tests
console.warn('Failed to clear Redis cache in test:', error);
}
});
describe('POST /api/vehicles', () => {
it('should create a new vehicle', async () => {
const vehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'My Test Car',
color: 'Blue',
odometerReading: 50000
};
const response = await request(app)
.post('/api/vehicles')
.send(vehicleData)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
userId: 'test-user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Test Car',
color: 'Blue',
odometerReading: 50000,
isActive: true,
createdAt: expect.any(String),
updatedAt: expect.any(String)
});
});
it('should reject invalid VIN', async () => {
const vehicleData = {
vin: 'INVALID',
nickname: 'Test Car'
};
const response = await request(app)
.post('/api/vehicles')
.send(vehicleData)
.expect(400);
expect(response.body.error).toMatch(/VIN/);
});
it('should reject duplicate VIN for same user', async () => {
const vehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'First Car'
};
// Create first vehicle
await request(app)
.post('/api/vehicles')
.send(vehicleData)
.expect(201);
// Try to create duplicate
const response = await request(app)
.post('/api/vehicles')
.send({ ...vehicleData, nickname: 'Duplicate Car' })
.expect(400);
expect(response.body.error).toBe('Vehicle with this VIN already exists');
});
});
describe('GET /api/vehicles', () => {
it('should return user vehicles', async () => {
// Create test vehicles
await pool.query(`
INSERT INTO vehicles (user_id, vin, make, model, year, nickname)
VALUES
($1, $2, $3, $4, $5, $6),
($7, $8, $9, $10, $11, $12)
`, [
'test-user-123', '1HGBH41JXMN109186', 'Honda', 'Civic', 2021, 'Car 1',
'test-user-123', '1HGBH41JXMN109187', 'Toyota', 'Camry', 2020, 'Car 2'
]);
const response = await request(app)
.get('/api/vehicles')
.expect(200);
expect(response.body).toHaveLength(2);
expect(response.body[0]).toMatchObject({
userId: 'test-user-123',
vin: expect.any(String),
nickname: expect.any(String)
});
});
it('should return empty array for user with no vehicles', async () => {
const response = await request(app)
.get('/api/vehicles')
.expect(200);
expect(response.body).toEqual([]);
});
});
describe('GET /api/vehicles/:id', () => {
it('should return specific vehicle', async () => {
// Create test vehicle
const result = await pool.query(`
INSERT INTO vehicles (user_id, vin, make, model, year, nickname)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, ['test-user-123', '1HGBH41JXMN109186', 'Honda', 'Civic', 2021, 'Test Car']);
const vehicleId = result.rows[0].id;
const response = await request(app)
.get(`/api/vehicles/${vehicleId}`)
.expect(200);
expect(response.body).toMatchObject({
id: vehicleId,
userId: 'test-user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
nickname: 'Test Car'
});
});
it('should return 404 for non-existent vehicle', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
const response = await request(app)
.get(`/api/vehicles/${fakeId}`)
.expect(404);
expect(response.body.error).toBe('Vehicle not found');
});
it('should return 400 for invalid UUID format', async () => {
const response = await request(app)
.get('/api/vehicles/invalid-id')
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
describe('PUT /api/vehicles/:id', () => {
it('should update vehicle', async () => {
// Create test vehicle
const result = await pool.query(`
INSERT INTO vehicles (user_id, vin, nickname, color)
VALUES ($1, $2, $3, $4)
RETURNING id
`, ['test-user-123', '1HGBH41JXMN109186', 'Old Name', 'Blue']);
const vehicleId = result.rows[0].id;
const updateData = {
nickname: 'Updated Name',
color: 'Red',
odometerReading: 75000
};
const response = await request(app)
.put(`/api/vehicles/${vehicleId}`)
.send(updateData)
.expect(200);
expect(response.body).toMatchObject({
id: vehicleId,
nickname: 'Updated Name',
color: 'Red',
odometerReading: 75000
});
});
it('should return 404 for non-existent vehicle', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
const response = await request(app)
.put(`/api/vehicles/${fakeId}`)
.send({ nickname: 'Updated' })
.expect(404);
expect(response.body.error).toBe('Vehicle not found');
});
});
describe('DELETE /api/vehicles/:id', () => {
it('should soft delete vehicle', async () => {
// Create test vehicle
const result = await pool.query(`
INSERT INTO vehicles (user_id, vin, nickname)
VALUES ($1, $2, $3)
RETURNING id
`, ['test-user-123', '1HGBH41JXMN109186', 'Test Car']);
const vehicleId = result.rows[0].id;
await request(app)
.delete(`/api/vehicles/${vehicleId}`)
.expect(204);
// Verify vehicle is soft deleted
const checkResult = await pool.query(
'SELECT is_active, deleted_at FROM vehicles WHERE id = $1',
[vehicleId]
);
expect(checkResult.rows[0].is_active).toBe(false);
expect(checkResult.rows[0].deleted_at).toBeTruthy();
});
it('should return 404 for non-existent vehicle', async () => {
const fakeId = '550e8400-e29b-41d4-a716-446655440000';
const response = await request(app)
.delete(`/api/vehicles/${fakeId}`)
.expect(404);
expect(response.body.error).toBe('Vehicle not found');
});
});
});

View File

@@ -0,0 +1,305 @@
/**
* @ai-summary Unit tests for VehiclesService
* @ai-context Tests business logic with mocked dependencies
*/
import { VehiclesService } from '../../domain/vehicles.service';
import { VehiclesRepository } from '../../data/vehicles.repository';
import { vpicClient } from '../../external/vpic/vpic.client';
import { cacheService } from '../../../../core/config/redis';
// Mock dependencies
jest.mock('../../data/vehicles.repository');
jest.mock('../../external/vpic/vpic.client');
jest.mock('../../../../core/config/redis');
const mockRepository = jest.mocked(VehiclesRepository);
const mockVpicClient = jest.mocked(vpicClient);
const mockCacheService = jest.mocked(cacheService);
describe('VehiclesService', () => {
let service: VehiclesService;
let repositoryInstance: jest.Mocked<VehiclesRepository>;
beforeEach(() => {
jest.clearAllMocks();
repositoryInstance = {
create: jest.fn(),
findByUserId: jest.fn(),
findById: jest.fn(),
findByUserAndVIN: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
cacheVINDecode: jest.fn(),
getVINFromCache: jest.fn(),
} as any;
mockRepository.mockImplementation(() => repositoryInstance);
service = new VehiclesService(repositoryInstance);
});
describe('createVehicle', () => {
const mockVehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'My Car',
color: 'Blue',
odometerReading: 50000,
};
const mockVinDecodeResult = {
make: 'Honda',
model: 'Civic',
year: 2021,
engineType: '2.0L',
bodyType: 'Sedan',
rawData: [],
};
const mockCreatedVehicle = {
id: 'vehicle-id-123',
userId: 'user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
odometerReading: 50000,
isActive: true,
deletedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
it('should create a vehicle with VIN decoding', async () => {
repositoryInstance.findByUserAndVIN.mockResolvedValue(null);
mockVpicClient.decodeVIN.mockResolvedValue(mockVinDecodeResult);
repositoryInstance.create.mockResolvedValue(mockCreatedVehicle);
repositoryInstance.cacheVINDecode.mockResolvedValue(undefined);
mockCacheService.del.mockResolvedValue(undefined);
const result = await service.createVehicle(mockVehicleData, 'user-123');
expect(repositoryInstance.findByUserAndVIN).toHaveBeenCalledWith('user-123', '1HGBH41JXMN109186');
expect(mockVpicClient.decodeVIN).toHaveBeenCalledWith('1HGBH41JXMN109186');
expect(repositoryInstance.create).toHaveBeenCalledWith({
...mockVehicleData,
userId: 'user-123',
make: 'Honda',
model: 'Civic',
year: 2021,
});
expect(repositoryInstance.cacheVINDecode).toHaveBeenCalledWith('1HGBH41JXMN109186', mockVinDecodeResult);
expect(result.id).toBe('vehicle-id-123');
expect(result.make).toBe('Honda');
});
it('should reject invalid VIN format', async () => {
const invalidVin = { ...mockVehicleData, vin: 'INVALID' };
await expect(service.createVehicle(invalidVin, 'user-123')).rejects.toThrow('Invalid VIN format');
});
it('should reject duplicate VIN for same user', async () => {
repositoryInstance.findByUserAndVIN.mockResolvedValue(mockCreatedVehicle);
await expect(service.createVehicle(mockVehicleData, 'user-123')).rejects.toThrow('Vehicle with this VIN already exists');
});
it('should handle VIN decode failure gracefully', async () => {
repositoryInstance.findByUserAndVIN.mockResolvedValue(null);
mockVpicClient.decodeVIN.mockResolvedValue(null);
repositoryInstance.create.mockResolvedValue({ ...mockCreatedVehicle, make: undefined, model: undefined, year: undefined });
mockCacheService.del.mockResolvedValue(undefined);
const result = await service.createVehicle(mockVehicleData, 'user-123');
expect(repositoryInstance.create).toHaveBeenCalledWith({
...mockVehicleData,
userId: 'user-123',
make: undefined,
model: undefined,
year: undefined,
});
expect(result.make).toBeUndefined();
});
});
describe('getUserVehicles', () => {
it('should return cached vehicles if available', async () => {
const cachedVehicles = [{ id: 'vehicle-1', vin: '1HGBH41JXMN109186' }];
mockCacheService.get.mockResolvedValue(cachedVehicles);
const result = await service.getUserVehicles('user-123');
expect(mockCacheService.get).toHaveBeenCalledWith('vehicles:user:user-123');
expect(result).toBe(cachedVehicles);
expect(repositoryInstance.findByUserId).not.toHaveBeenCalled();
});
it('should fetch from database and cache if not cached', async () => {
const mockVehicles = [
{
id: 'vehicle-id-123',
userId: 'user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
odometerReading: 50000,
isActive: true,
deletedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
}
];
mockCacheService.get.mockResolvedValue(null);
repositoryInstance.findByUserId.mockResolvedValue(mockVehicles);
mockCacheService.set.mockResolvedValue(undefined);
const result = await service.getUserVehicles('user-123');
expect(repositoryInstance.findByUserId).toHaveBeenCalledWith('user-123');
expect(mockCacheService.set).toHaveBeenCalledWith('vehicles:user:user-123', expect.any(Array), 300);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('vehicle-id-123');
});
});
describe('getVehicle', () => {
const mockVehicle = {
id: 'vehicle-id-123',
userId: 'user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
odometerReading: 50000,
isActive: true,
deletedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
it('should return vehicle if found and owned by user', async () => {
repositoryInstance.findById.mockResolvedValue(mockVehicle);
const result = await service.getVehicle('vehicle-id-123', 'user-123');
expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123');
expect(result.id).toBe('vehicle-id-123');
});
it('should throw error if vehicle not found', async () => {
repositoryInstance.findById.mockResolvedValue(null);
await expect(service.getVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Vehicle not found');
});
it('should throw error if user is not owner', async () => {
repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' });
await expect(service.getVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized');
});
});
describe('updateVehicle', () => {
const mockVehicle = {
id: 'vehicle-id-123',
userId: 'user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
odometerReading: 50000,
isActive: true,
deletedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
it('should update vehicle successfully', async () => {
const updateData = { nickname: 'Updated Car', color: 'Red' };
const updatedVehicle = { ...mockVehicle, ...updateData };
repositoryInstance.findById.mockResolvedValue(mockVehicle);
repositoryInstance.update.mockResolvedValue(updatedVehicle);
mockCacheService.del.mockResolvedValue(undefined);
const result = await service.updateVehicle('vehicle-id-123', updateData, 'user-123');
expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123');
expect(repositoryInstance.update).toHaveBeenCalledWith('vehicle-id-123', updateData);
expect(mockCacheService.del).toHaveBeenCalledWith('vehicles:user:user-123');
expect(result.nickname).toBe('Updated Car');
expect(result.color).toBe('Red');
});
it('should throw error if vehicle not found', async () => {
repositoryInstance.findById.mockResolvedValue(null);
await expect(service.updateVehicle('vehicle-id-123', {}, 'user-123')).rejects.toThrow('Vehicle not found');
});
it('should throw error if user is not owner', async () => {
repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' });
await expect(service.updateVehicle('vehicle-id-123', {}, 'user-123')).rejects.toThrow('Unauthorized');
});
});
describe('deleteVehicle', () => {
const mockVehicle = {
id: 'vehicle-id-123',
userId: 'user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
odometerReading: 50000,
isActive: true,
deletedAt: undefined,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
it('should delete vehicle successfully', async () => {
repositoryInstance.findById.mockResolvedValue(mockVehicle);
repositoryInstance.softDelete.mockResolvedValue(true);
mockCacheService.del.mockResolvedValue(undefined);
await service.deleteVehicle('vehicle-id-123', 'user-123');
expect(repositoryInstance.findById).toHaveBeenCalledWith('vehicle-id-123');
expect(repositoryInstance.softDelete).toHaveBeenCalledWith('vehicle-id-123');
expect(mockCacheService.del).toHaveBeenCalledWith('vehicles:user:user-123');
});
it('should throw error if vehicle not found', async () => {
repositoryInstance.findById.mockResolvedValue(null);
await expect(service.deleteVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Vehicle not found');
});
it('should throw error if user is not owner', async () => {
repositoryInstance.findById.mockResolvedValue({ ...mockVehicle, userId: 'other-user' });
await expect(service.deleteVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized');
});
});
});

View File

@@ -0,0 +1,161 @@
/**
* @ai-summary Unit tests for VPICClient
* @ai-context Tests VIN decoding with mocked HTTP client
*/
import axios from 'axios';
import { VPICClient } from '../../external/vpic/vpic.client';
import { cacheService } from '../../../../core/config/redis';
import { VPICResponse } from '../../external/vpic/vpic.types';
jest.mock('axios');
jest.mock('../../../../core/config/redis');
const mockAxios = jest.mocked(axios);
const mockCacheService = jest.mocked(cacheService);
describe('VPICClient', () => {
let client: VPICClient;
beforeEach(() => {
jest.clearAllMocks();
client = new VPICClient();
});
describe('decodeVIN', () => {
const mockVin = '1HGBH41JXMN109186';
const mockVPICResponse: VPICResponse = {
Count: 3,
Message: 'Success',
SearchCriteria: 'VIN: 1HGBH41JXMN109186',
Results: [
{ Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 },
{ Variable: 'Model', Value: 'Civic', ValueId: null, VariableId: 2 },
{ Variable: 'Model Year', Value: '2021', ValueId: null, VariableId: 3 },
{ Variable: 'Engine Model', Value: '2.0L', ValueId: null, VariableId: 4 },
{ Variable: 'Body Class', Value: 'Sedan', ValueId: null, VariableId: 5 },
]
};
it('should return cached result if available', async () => {
const cachedResult = {
make: 'Honda',
model: 'Civic',
year: 2021,
engineType: '2.0L',
bodyType: 'Sedan',
rawData: mockVPICResponse.Results
};
mockCacheService.get.mockResolvedValue(cachedResult);
const result = await client.decodeVIN(mockVin);
expect(mockCacheService.get).toHaveBeenCalledWith(`vpic:vin:${mockVin}`);
expect(result).toEqual(cachedResult);
expect(mockAxios.get).not.toHaveBeenCalled();
});
it('should fetch and cache VIN data when not cached', async () => {
mockCacheService.get.mockResolvedValue(null);
mockAxios.get.mockResolvedValue({ data: mockVPICResponse });
mockCacheService.set.mockResolvedValue(undefined);
const result = await client.decodeVIN(mockVin);
expect(mockCacheService.get).toHaveBeenCalledWith(`vpic:vin:${mockVin}`);
expect(mockAxios.get).toHaveBeenCalledWith(
expect.stringContaining(`/DecodeVin/${mockVin}?format=json`)
);
expect(mockCacheService.set).toHaveBeenCalledWith(
`vpic:vin:${mockVin}`,
expect.objectContaining({
make: 'Honda',
model: 'Civic',
year: 2021,
engineType: '2.0L',
bodyType: 'Sedan'
}),
30 * 24 * 60 * 60 // 30 days
);
expect(result?.make).toBe('Honda');
expect(result?.model).toBe('Civic');
expect(result?.year).toBe(2021);
});
it('should return null when API returns no results', async () => {
const emptyResponse: VPICResponse = {
Count: 0,
Message: 'No data found',
SearchCriteria: 'VIN: INVALID',
Results: []
};
mockCacheService.get.mockResolvedValue(null);
mockAxios.get.mockResolvedValue({ data: emptyResponse });
const result = await client.decodeVIN('INVALID');
expect(result).toBeNull();
expect(mockCacheService.set).not.toHaveBeenCalled();
});
it('should return null when required fields are missing', async () => {
const incompleteResponse: VPICResponse = {
Count: 1,
Message: 'Success',
SearchCriteria: 'VIN: 1HGBH41JXMN109186',
Results: [
{ Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 },
// Missing Model and Year
]
};
mockCacheService.get.mockResolvedValue(null);
mockAxios.get.mockResolvedValue({ data: incompleteResponse });
const result = await client.decodeVIN(mockVin);
expect(result).toBeNull();
expect(mockCacheService.set).not.toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
mockCacheService.get.mockResolvedValue(null);
mockAxios.get.mockRejectedValue(new Error('Network error'));
const result = await client.decodeVIN(mockVin);
expect(result).toBeNull();
expect(mockCacheService.set).not.toHaveBeenCalled();
});
it('should handle null values in API response', async () => {
const responseWithNulls: VPICResponse = {
Count: 3,
Message: 'Success',
SearchCriteria: 'VIN: 1HGBH41JXMN109186',
Results: [
{ Variable: 'Make', Value: 'Honda', ValueId: null, VariableId: 1 },
{ Variable: 'Model', Value: 'Civic', ValueId: null, VariableId: 2 },
{ Variable: 'Model Year', Value: '2021', ValueId: null, VariableId: 3 },
{ Variable: 'Engine Model', Value: null, ValueId: null, VariableId: 4 },
{ Variable: 'Body Class', Value: null, ValueId: null, VariableId: 5 },
]
};
mockCacheService.get.mockResolvedValue(null);
mockAxios.get.mockResolvedValue({ data: responseWithNulls });
mockCacheService.set.mockResolvedValue(undefined);
const result = await client.decodeVIN(mockVin);
expect(result?.make).toBe('Honda');
expect(result?.model).toBe('Civic');
expect(result?.year).toBe(2021);
expect(result?.engineType).toBeUndefined();
expect(result?.bodyType).toBeUndefined();
});
});
});