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,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();
});
});
});