Homepage Redesign

This commit is contained in:
Eric Gullickson
2025-11-03 14:06:54 -06:00
parent 54d97a98b5
commit eeb20543fa
71 changed files with 3925 additions and 1340 deletions

View File

@@ -0,0 +1,379 @@
# Platform Feature Capsule
## Quick Summary (50 tokens)
Extensible platform service for vehicle hierarchical data lookups and VIN decoding. Replaces Python FastAPI platform service. PostgreSQL-first with vPIC fallback, Redis caching (6hr vehicle data, 7-day VIN), circuit breaker pattern for resilience.
## API Endpoints
### Vehicle Hierarchical Data
- `GET /api/platform/years` - Get available model years (distinct, descending)
- `GET /api/platform/makes?year={year}` - Get makes for specific year
- `GET /api/platform/models?year={year}&make_id={id}` - Get models for year and make
- `GET /api/platform/trims?year={year}&model_id={id}` - Get trims for year and model
- `GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}` - Get engines for trim
### VIN Decoding
- `GET /api/platform/vehicle?vin={vin}` - Decode VIN to vehicle details
## Authentication
- All platform endpoints require valid JWT (Auth0)
## Request/Response Examples
### Get Years
```json
GET /api/platform/years
Response (200):
[2024, 2023, 2022, 2021, ...]
```
### Get Makes for Year
```json
GET /api/platform/makes?year=2024
Response (200):
{
"makes": [
{"id": 1, "name": "Honda"},
{"id": 2, "name": "Toyota"},
{"id": 3, "name": "Ford"}
]
}
```
### Get Models for Make/Year
```json
GET /api/platform/models?year=2024&make_id=1
Response (200):
{
"models": [
{"id": 101, "name": "Civic"},
{"id": 102, "name": "Accord"},
{"id": 103, "name": "CR-V"}
]
}
```
### Decode VIN
```json
GET /api/platform/vehicle?vin=1HGCM82633A123456
Response (200):
{
"vin": "1HGCM82633A123456",
"success": true,
"result": {
"make": "Honda",
"model": "Accord",
"year": 2003,
"trim_name": "LX",
"engine_description": "2.4L I4",
"transmission_description": "5-Speed Automatic",
"horsepower": 160,
"torque": 161,
"top_speed": null,
"fuel": "Gasoline",
"confidence_score": 0.95,
"vehicle_type": "Passenger Car"
}
}
```
### VIN Decode Error
```json
GET /api/platform/vehicle?vin=INVALID
Response (400):
{
"vin": "INVALID",
"success": false,
"result": null,
"error": "VIN must be exactly 17 characters"
}
```
## Feature Architecture
### Complete Self-Contained Structure
```
platform/
├── README.md # This file
├── index.ts # Public API exports
├── api/ # HTTP layer
│ ├── platform.controller.ts
│ └── platform.routes.ts
├── domain/ # Business logic
│ ├── vehicle-data.service.ts
│ ├── vin-decode.service.ts
│ └── platform-cache.service.ts
├── data/ # Database and external APIs
│ ├── vehicle-data.repository.ts
│ └── vpic-client.ts
├── models/ # DTOs
│ ├── requests.ts
│ └── responses.ts
├── tests/ # All tests
│ ├── unit/
│ └── integration/
└── docs/ # Additional docs
```
## Key Features
### VIN Decoding Strategy
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
2. **PostgreSQL**: Use `vehicles.f_decode_vin()` function for high-confidence decode
3. **vPIC Fallback**: NHTSA vPIC API via circuit breaker (5s timeout, 50% error threshold)
4. **Graceful Degradation**: Return meaningful errors when all sources fail
### Circuit Breaker Pattern
- **Library**: opossum
- **Timeout**: 6 seconds
- **Error Threshold**: 50%
- **Reset Timeout**: 30 seconds
- **Monitoring**: Logs state transitions (open/half-open/close)
### Hierarchical Vehicle Data
- **PostgreSQL Queries**: Normalized schema (vehicles.make, vehicles.model, etc.)
- **Caching**: 6-hour TTL for all dropdown data
- **Performance**: < 100ms response times via caching
- **Validation**: Year (1950-2100), positive integer IDs
### Database Schema
- **Uses Existing Schema**: `vehicles` schema in PostgreSQL
- **Tables**: make, model, model_year, trim, engine, trim_engine
- **Function**: `vehicles.f_decode_vin(vin text)` for VIN decoding
- **No Migrations**: Uses existing platform database structure
### Caching Strategy
#### Vehicle Data (6 hours)
- **Keys**: `mvp:platform:vehicle-data:{type}:{params}`
- **Examples**:
- `mvp:platform:years`
- `mvp:platform:vehicle-data:makes:2024`
- `mvp:platform:vehicle-data:models:2024:1`
- **TTL**: 21600 seconds (6 hours)
- **Invalidation**: Natural expiration via TTL
#### VIN Decode (7 days success, 1 hour failure)
- **Keys**: `mvp:platform:vin-decode:{VIN}`
- **Examples**: `mvp:platform:vin-decode:1HGCM82633A123456`
- **TTL**: 604800 seconds (7 days) for success, 3600 seconds (1 hour) for failures
- **Invalidation**: Natural expiration via TTL
## Business Rules
### VIN Validation
- Must be exactly 17 characters
- Cannot contain letters I, O, or Q
- Must be alphanumeric
- Auto-uppercase normalization
### Query Parameter Validation
- **Year**: Integer between 1950 and 2100
- **IDs**: Positive integers (make_id, model_id, trim_id)
- **VIN**: 17 alphanumeric characters (no I, O, Q)
## Dependencies
### Internal Core Services
- `core/config/database` - PostgreSQL pool
- `core/config/redis` - Redis cache service
- `core/auth` - JWT authentication middleware
- `core/logging` - Winston structured logging
### External APIs
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api
- VIN decoding fallback
- 5-second timeout
- Circuit breaker protected
- Free public API
### Database Schema
- **vehicles.make** - Vehicle manufacturers
- **vehicles.model** - Vehicle models
- **vehicles.model_year** - Year-specific models
- **vehicles.trim** - Model trims
- **vehicles.engine** - Engine configurations
- **vehicles.trim_engine** - Trim-engine relationships
- **vehicles.f_decode_vin(text)** - VIN decode function
### NPM Packages
- `opossum` - Circuit breaker implementation
- `axios` - HTTP client for vPIC API
- `zod` - Request validation schemas
## Performance Optimizations
### Caching Strategy
- **6-hour TTL**: Vehicle data rarely changes
- **7-day TTL**: VIN decode results are immutable
- **1-hour TTL**: Failed VIN decode (prevent repeated failures)
- **Cache Prefix**: `mvp:platform:` for isolation
### Circuit Breaker
- Prevents cascading failures to vPIC API
- 30-second cooldown after opening
- Automatic recovery via half-open state
- Detailed logging for monitoring
### Database Optimization
- Uses existing indexes on vehicles schema
- Prepared statements via node-postgres
- Connection pooling (max 10 connections)
## Error Handling
### Client Errors (4xx)
- `400` - Invalid VIN format, validation errors
- `401` - Missing or invalid JWT token
- `404` - VIN not found in database or API
- `503` - vPIC API unavailable (circuit breaker open)
### Server Errors (5xx)
- `500` - Database errors, unexpected failures
- Graceful degradation when external APIs unavailable
- Detailed logging without exposing internal details
### Error Response Format
```json
{
"vin": "1HGCM82633A123456",
"success": false,
"result": null,
"error": "VIN not found in database and external API unavailable"
}
```
## Extensibility Design
### Future Lookup Types
The platform feature is designed to accommodate additional lookup types beyond vehicle data:
**Current**: Vehicle hierarchical data, VIN decoding
**Future Examples**:
- Part number lookups
- Service bulletins
- Recall information
- Maintenance schedules
- Tire specifications
- Paint codes
### Extension Pattern
1. Create new service in `domain/` (e.g., `part-lookup.service.ts`)
2. Add repository in `data/` if database queries needed
3. Add external client in `data/` if API integration needed
4. Add routes in `api/platform.routes.ts`
5. Add validation schemas in `models/requests.ts`
6. Add response types in `models/responses.ts`
7. Update controller with new methods
## Testing
### Unit Tests
- `vehicle-data.service.test.ts` - Business logic with mocked dependencies
- `vin-decode.service.test.ts` - VIN decode logic with mocked API
- `vpic-client.test.ts` - vPIC client with mocked HTTP
- `platform-cache.service.test.ts` - Cache operations
### Integration Tests
- `platform.integration.test.ts` - Complete API workflow with test database
### Run Tests
```bash
# All platform tests
npm test -- features/platform
# Unit tests only
npm test -- features/platform/tests/unit
# Integration tests only
npm test -- features/platform/tests/integration
# With coverage
npm test -- features/platform --coverage
```
## Migration from Python Service
### What Changed
- **Language**: Python FastAPI -> TypeScript Fastify
- **Feature Name**: "vehicles" -> "platform" (extensibility)
- **API Routes**: `/vehicles/*` -> `/api/platform/*`
- **VIN Decode**: Kept and migrated (PostgreSQL + vPIC fallback)
- **Caching**: Redis implementation adapted to TypeScript
- **Circuit Breaker**: Python timeout -> opossum circuit breaker
### What Stayed the Same
- Database schema (vehicles.*)
- Cache TTLs (6hr vehicle data, 7-day VIN)
- VIN validation logic
- Hierarchical query structure
- Response formats
### Deprecation Plan
1. Deploy TypeScript platform feature
2. Update frontend to use `/api/platform/*` endpoints
3. Monitor traffic to Python service
4. Deprecate Python service when traffic drops to zero
5. Remove Python container from docker-compose
## Development Commands
```bash
# Start environment
make start
# View feature logs
make logs-backend | grep platform
# Open container shell
make shell-backend
# Inside container - run feature tests
npm test -- features/platform
# Type check
npm run type-check
# Lint
npm run lint
```
## Future Considerations
### Potential Enhancements
- Batch VIN decode endpoint (decode multiple VINs)
- Admin endpoint to invalidate cache patterns
- VIN decode history tracking
- Alternative VIN decode APIs (CarMD, Edmunds)
- Real-time vehicle data updates
- Part number cross-reference lookups
- Service bulletin integration
- Recall information integration
### Performance Monitoring
- Track cache hit rates
- Monitor circuit breaker state
- Log slow queries (> 200ms)
- Alert on high error rates
- Dashboard for vPIC API health
## Related Features
### Vehicles Feature
- **Path**: `backend/src/features/vehicles/`
- **Relationship**: Consumes platform VIN decode endpoint
- **Integration**: Uses `/api/platform/vehicle?vin={vin}` for VIN decode
### Frontend Integration
- **Dropdown Components**: Use hierarchical vehicle data endpoints
- **VIN Scanner**: Use VIN decode endpoint for auto-population
- **Vehicle Forms**: Leverage platform data for validation
---
**Platform Feature**: Extensible foundation for vehicle data and future platform capabilities. Production-ready with PostgreSQL, Redis caching, circuit breaker resilience, and comprehensive error handling.

View File

@@ -0,0 +1,131 @@
/**
* @ai-summary Platform API controller
* @ai-context Request handlers for vehicle data and VIN decoding endpoints
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { Pool } from 'pg';
import { VehicleDataService } from '../domain/vehicle-data.service';
import { VINDecodeService } from '../domain/vin-decode.service';
import { PlatformCacheService } from '../domain/platform-cache.service';
import { cacheService } from '../../../core/config/redis';
import {
MakesQuery,
ModelsQuery,
TrimsQuery,
EnginesQuery,
VINDecodeRequest
} from '../models/requests';
import { logger } from '../../../core/logging/logger';
export class PlatformController {
private vehicleDataService: VehicleDataService;
private vinDecodeService: VINDecodeService;
private pool: Pool;
constructor(pool: Pool) {
this.pool = pool;
const platformCache = new PlatformCacheService(cacheService);
this.vehicleDataService = new VehicleDataService(platformCache);
this.vinDecodeService = new VINDecodeService(platformCache);
}
/**
* GET /api/platform/years
*/
async getYears(_request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const years = await this.vehicleDataService.getYears(this.pool);
reply.code(200).send(years);
} catch (error) {
logger.error('Controller error: getYears', { error });
reply.code(500).send({ error: 'Failed to retrieve years' });
}
}
/**
* GET /api/platform/makes?year={year}
*/
async getMakes(request: FastifyRequest<{ Querystring: MakesQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year } = request.query;
const makes = await this.vehicleDataService.getMakes(this.pool, year);
reply.code(200).send({ makes });
} catch (error) {
logger.error('Controller error: getMakes', { error, query: request.query });
reply.code(500).send({ error: 'Failed to retrieve makes' });
}
}
/**
* GET /api/platform/models?year={year}&make_id={id}
*/
async getModels(request: FastifyRequest<{ Querystring: ModelsQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, make_id } = request.query;
const models = await this.vehicleDataService.getModels(this.pool, year, make_id);
reply.code(200).send({ models });
} catch (error) {
logger.error('Controller error: getModels', { error, query: request.query });
reply.code(500).send({ error: 'Failed to retrieve models' });
}
}
/**
* GET /api/platform/trims?year={year}&model_id={id}
*/
async getTrims(request: FastifyRequest<{ Querystring: TrimsQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, model_id } = request.query;
const trims = await this.vehicleDataService.getTrims(this.pool, year, model_id);
reply.code(200).send({ trims });
} catch (error) {
logger.error('Controller error: getTrims', { error, query: request.query });
reply.code(500).send({ error: 'Failed to retrieve trims' });
}
}
/**
* GET /api/platform/engines?year={year}&model_id={id}&trim_id={id}
*/
async getEngines(request: FastifyRequest<{ Querystring: EnginesQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, model_id, trim_id } = request.query;
const engines = await this.vehicleDataService.getEngines(this.pool, year, model_id, trim_id);
reply.code(200).send({ engines });
} catch (error) {
logger.error('Controller error: getEngines', { error, query: request.query });
reply.code(500).send({ error: 'Failed to retrieve engines' });
}
}
/**
* GET /api/platform/vehicle?vin={vin}
*/
async decodeVIN(request: FastifyRequest<{ Querystring: VINDecodeRequest }>, reply: FastifyReply): Promise<void> {
try {
const { vin } = request.query;
const result = await this.vinDecodeService.decodeVIN(this.pool, vin);
if (!result.success) {
if (result.error && result.error.includes('Invalid VIN')) {
reply.code(400).send(result);
} else if (result.error && result.error.includes('unavailable')) {
reply.code(503).send(result);
} else {
reply.code(404).send(result);
}
return;
}
reply.code(200).send(result);
} catch (error) {
logger.error('Controller error: decodeVIN', { error, query: request.query });
reply.code(500).send({
vin: request.query.vin,
result: null,
success: false,
error: 'Internal server error during VIN decoding'
});
}
}
}

View File

@@ -0,0 +1,46 @@
/**
* @ai-summary Platform feature routes
* @ai-context Fastify route registration with validation
*/
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { PlatformController } from './platform.controller';
import {
MakesQuery,
ModelsQuery,
TrimsQuery,
EnginesQuery,
VINDecodeRequest
} from '../models/requests';
import pool from '../../../core/config/database';
async function platformRoutes(fastify: FastifyInstance) {
const controller = new PlatformController(pool);
fastify.get('/platform/years', {
preHandler: [fastify.authenticate]
}, controller.getYears.bind(controller));
fastify.get<{ Querystring: MakesQuery }>('/platform/makes', {
preHandler: [fastify.authenticate]
}, controller.getMakes.bind(controller));
fastify.get<{ Querystring: ModelsQuery }>('/platform/models', {
preHandler: [fastify.authenticate]
}, controller.getModels.bind(controller));
fastify.get<{ Querystring: TrimsQuery }>('/platform/trims', {
preHandler: [fastify.authenticate]
}, controller.getTrims.bind(controller));
fastify.get<{ Querystring: EnginesQuery }>('/platform/engines', {
preHandler: [fastify.authenticate]
}, controller.getEngines.bind(controller));
fastify.get<{ Querystring: VINDecodeRequest }>('/platform/vehicle', {
preHandler: [fastify.authenticate]
}, controller.decodeVIN.bind(controller));
}
export default fastifyPlugin(platformRoutes);
export { platformRoutes };

View File

@@ -0,0 +1,165 @@
/**
* @ai-summary Vehicle data repository for hierarchical queries
* @ai-context PostgreSQL queries against vehicles schema
*/
import { Pool } from 'pg';
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
import { VINDecodeResult } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VehicleDataRepository {
/**
* Get distinct years from model_year table
*/
async getYears(pool: Pool): Promise<number[]> {
const query = `
SELECT DISTINCT year
FROM vehicles.model_year
ORDER BY year DESC
`;
try {
const result = await pool.query(query);
return result.rows.map(row => row.year);
} catch (error) {
logger.error('Repository error: getYears', { error });
throw new Error('Failed to retrieve years from database');
}
}
/**
* Get makes for a specific year
*/
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
const query = `
SELECT DISTINCT ma.id, ma.name
FROM vehicles.make ma
JOIN vehicles.model mo ON mo.make_id = ma.id
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
ORDER BY ma.name
`;
try {
const result = await pool.query(query, [year]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
} catch (error) {
logger.error('Repository error: getMakes', { error, year });
throw new Error(`Failed to retrieve makes for year ${year}`);
}
}
/**
* Get models for a specific year and make
*/
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
const query = `
SELECT DISTINCT mo.id, mo.name
FROM vehicles.model mo
JOIN vehicles.model_year my ON my.model_id = mo.id AND my.year = $1
WHERE mo.make_id = $2
ORDER BY mo.name
`;
try {
const result = await pool.query(query, [year, makeId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
} catch (error) {
logger.error('Repository error: getModels', { error, year, makeId });
throw new Error(`Failed to retrieve models for year ${year}, make ${makeId}`);
}
}
/**
* Get trims for a specific year and model
*/
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
const query = `
SELECT t.id, t.name
FROM vehicles.trim t
JOIN vehicles.model_year my ON my.id = t.model_year_id
WHERE my.year = $1 AND my.model_id = $2
ORDER BY t.name
`;
try {
const result = await pool.query(query, [year, modelId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
} catch (error) {
logger.error('Repository error: getTrims', { error, year, modelId });
throw new Error(`Failed to retrieve trims for year ${year}, model ${modelId}`);
}
}
/**
* Get engines for a specific year, model, and trim
*/
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
const query = `
SELECT DISTINCT e.id, e.name
FROM vehicles.engine e
JOIN vehicles.trim_engine te ON te.engine_id = e.id
JOIN vehicles.trim t ON t.id = te.trim_id
JOIN vehicles.model_year my ON my.id = t.model_year_id
WHERE my.year = $1
AND my.model_id = $2
AND t.id = $3
ORDER BY e.name
`;
try {
const result = await pool.query(query, [year, modelId, trimId]);
return result.rows.map(row => ({
id: row.id,
name: row.name
}));
} catch (error) {
logger.error('Repository error: getEngines', { error, year, modelId, trimId });
throw new Error(`Failed to retrieve engines for year ${year}, model ${modelId}, trim ${trimId}`);
}
}
/**
* Decode VIN using PostgreSQL function
*/
async decodeVIN(pool: Pool, vin: string): Promise<VINDecodeResult | null> {
const query = `
SELECT * FROM vehicles.f_decode_vin($1)
`;
try {
const result = await pool.query(query, [vin]);
if (result.rows.length === 0) {
return null;
}
const row = result.rows[0];
return {
make: row.make || null,
model: row.model || null,
year: row.year || null,
trim_name: row.trim_name || null,
engine_description: row.engine_description || null,
transmission_description: row.transmission_description || null,
horsepower: row.horsepower || null,
torque: row.torque || null,
top_speed: row.top_speed || null,
fuel: row.fuel || null,
confidence_score: row.confidence_score ? parseFloat(row.confidence_score) : null,
vehicle_type: row.vehicle_type || null
};
} catch (error) {
logger.error('Repository error: decodeVIN', { error, vin });
throw new Error(`Failed to decode VIN ${vin}`);
}
}
}

View File

@@ -0,0 +1,125 @@
/**
* @ai-summary NHTSA vPIC API client for VIN decoding fallback
* @ai-context External API client with timeout and error handling
*/
import axios, { AxiosInstance } from 'axios';
import { VPICResponse, VINDecodeResult } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VPICClient {
private client: AxiosInstance;
private readonly baseURL = 'https://vpic.nhtsa.dot.gov/api';
private readonly timeout = 5000; // 5 seconds
constructor() {
this.client = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
headers: {
'Accept': 'application/json',
'User-Agent': 'MotoVaultPro/1.0'
}
});
}
/**
* Decode VIN using NHTSA vPIC API
*/
async decodeVIN(vin: string): Promise<VINDecodeResult | null> {
try {
const url = `/vehicles/DecodeVin/${vin}?format=json`;
logger.debug('Calling vPIC API', { url, vin });
const response = await this.client.get<VPICResponse>(url);
if (!response.data || !response.data.Results) {
logger.warn('vPIC API returned invalid response', { vin });
return null;
}
// Parse vPIC response into our format
const result = this.parseVPICResponse(response.data.Results);
if (!result.make || !result.model || !result.year) {
logger.warn('vPIC API returned incomplete data', { vin, result });
return null;
}
logger.info('Successfully decoded VIN via vPIC', { vin, make: result.make, model: result.model, year: result.year });
return result;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
logger.error('vPIC API timeout', { vin, timeout: this.timeout });
} else if (error.response) {
logger.error('vPIC API error response', {
vin,
status: error.response.status,
statusText: error.response.statusText
});
} else if (error.request) {
logger.error('vPIC API no response', { vin });
} else {
logger.error('vPIC API request error', { vin, error: error.message });
}
} else {
logger.error('Unexpected error calling vPIC', { vin, error });
}
return null;
}
}
/**
* Parse vPIC API response variables into our format
*/
private parseVPICResponse(results: Array<{ Variable: string; Value: string | null }>): VINDecodeResult {
const getValue = (variableName: string): string | null => {
const variable = results.find(v => v.Variable === variableName);
return variable?.Value || null;
};
const getNumberValue = (variableName: string): number | null => {
const value = getValue(variableName);
if (!value) return null;
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
};
return {
make: getValue('Make'),
model: getValue('Model'),
year: getNumberValue('Model Year'),
trim_name: getValue('Trim'),
engine_description: this.buildEngineDescription(results),
transmission_description: getValue('Transmission Style'),
horsepower: null, // vPIC doesn't provide horsepower
torque: null, // vPIC doesn't provide torque
top_speed: null, // vPIC doesn't provide top speed
fuel: getValue('Fuel Type - Primary'),
confidence_score: 0.5, // Lower confidence for vPIC fallback
vehicle_type: getValue('Vehicle Type')
};
}
/**
* Build engine description from multiple vPIC fields
*/
private buildEngineDescription(results: Array<{ Variable: string; Value: string | null }>): string | null {
const getValue = (variableName: string): string | null => {
const variable = results.find(v => v.Variable === variableName);
return variable?.Value || null;
};
const displacement = getValue('Displacement (L)');
const cylinders = getValue('Engine Number of Cylinders');
const configuration = getValue('Engine Configuration');
const parts: string[] = [];
if (displacement) parts.push(`${displacement}L`);
if (configuration) parts.push(configuration);
if (cylinders) parts.push(`${cylinders} cyl`);
return parts.length > 0 ? parts.join(' ') : null;
}
}

View File

@@ -0,0 +1,119 @@
/**
* @ai-summary Platform-specific Redis caching service
* @ai-context Caching layer for vehicle data and VIN decoding
*/
import { CacheService } from '../../../core/config/redis';
import { logger } from '../../../core/logging/logger';
export class PlatformCacheService {
private cacheService: CacheService;
private readonly prefix = 'platform:';
constructor(cacheService: CacheService) {
this.cacheService = cacheService;
}
/**
* Get cached years
*/
async getYears(): Promise<number[] | null> {
const key = this.prefix + 'years';
return await this.cacheService.get<number[]>(key);
}
/**
* Set cached years
*/
async setYears(years: number[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'years';
await this.cacheService.set(key, years, ttl);
}
/**
* Get cached makes for year
*/
async getMakes(year: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:makes:' + year;
return await this.cacheService.get<any[]>(key);
}
/**
* Set cached makes for year
*/
async setMakes(year: number, makes: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:makes:' + year;
await this.cacheService.set(key, makes, ttl);
}
/**
* Get cached models for year and make
*/
async getModels(year: number, makeId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
return await this.cacheService.get<any[]>(key);
}
/**
* Set cached models for year and make
*/
async setModels(year: number, makeId: number, models: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:models:' + year + ':' + makeId;
await this.cacheService.set(key, models, ttl);
}
/**
* Get cached trims for year and model
*/
async getTrims(year: number, modelId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
return await this.cacheService.get<any[]>(key);
}
/**
* Set cached trims for year and model
*/
async setTrims(year: number, modelId: number, trims: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:trims:' + year + ':' + modelId;
await this.cacheService.set(key, trims, ttl);
}
/**
* Get cached engines for year, model, and trim
*/
async getEngines(year: number, modelId: number, trimId: number): Promise<any[] | null> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
return await this.cacheService.get<any[]>(key);
}
/**
* Set cached engines for year, model, and trim
*/
async setEngines(year: number, modelId: number, trimId: number, engines: any[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + modelId + ':' + trimId;
await this.cacheService.set(key, engines, ttl);
}
/**
* Get cached VIN decode result
*/
async getVINDecode(vin: string): Promise<any | null> {
const key = this.prefix + 'vin-decode:' + vin.toUpperCase();
return await this.cacheService.get<any>(key);
}
/**
* Set cached VIN decode result (7 days for successful decodes, 1 hour for failures)
*/
async setVINDecode(vin: string, result: any, success: boolean = true): Promise<void> {
const key = this.prefix + 'vin-decode:' + vin.toUpperCase();
const ttl = success ? 7 * 24 * 3600 : 3600;
await this.cacheService.set(key, result, ttl);
}
/**
* Invalidate all vehicle data cache (for admin operations)
*/
async invalidateVehicleData(): Promise<void> {
logger.warn('Vehicle data cache invalidation not implemented (requires pattern deletion)');
}
}

View File

@@ -0,0 +1,124 @@
/**
* @ai-summary Vehicle data service with caching
* @ai-context Business logic for hierarchical vehicle data queries
*/
import { Pool } from 'pg';
import { VehicleDataRepository } from '../data/vehicle-data.repository';
import { PlatformCacheService } from './platform-cache.service';
import { MakeItem, ModelItem, TrimItem, EngineItem } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VehicleDataService {
private repository: VehicleDataRepository;
private cache: PlatformCacheService;
constructor(cache: PlatformCacheService, repository?: VehicleDataRepository) {
this.cache = cache;
this.repository = repository || new VehicleDataRepository();
}
/**
* Get available years with caching
*/
async getYears(pool: Pool): Promise<number[]> {
try {
const cached = await this.cache.getYears();
if (cached) {
logger.debug('Years retrieved from cache');
return cached;
}
const years = await this.repository.getYears(pool);
await this.cache.setYears(years);
logger.debug('Years retrieved from database and cached', { count: years.length });
return years;
} catch (error) {
logger.error('Service error: getYears', { error });
throw error;
}
}
/**
* Get makes for a year with caching
*/
async getMakes(pool: Pool, year: number): Promise<MakeItem[]> {
try {
const cached = await this.cache.getMakes(year);
if (cached) {
logger.debug('Makes retrieved from cache', { year });
return cached;
}
const makes = await this.repository.getMakes(pool, year);
await this.cache.setMakes(year, makes);
logger.debug('Makes retrieved from database and cached', { year, count: makes.length });
return makes;
} catch (error) {
logger.error('Service error: getMakes', { error, year });
throw error;
}
}
/**
* Get models for a year and make with caching
*/
async getModels(pool: Pool, year: number, makeId: number): Promise<ModelItem[]> {
try {
const cached = await this.cache.getModels(year, makeId);
if (cached) {
logger.debug('Models retrieved from cache', { year, makeId });
return cached;
}
const models = await this.repository.getModels(pool, year, makeId);
await this.cache.setModels(year, makeId, models);
logger.debug('Models retrieved from database and cached', { year, makeId, count: models.length });
return models;
} catch (error) {
logger.error('Service error: getModels', { error, year, makeId });
throw error;
}
}
/**
* Get trims for a year and model with caching
*/
async getTrims(pool: Pool, year: number, modelId: number): Promise<TrimItem[]> {
try {
const cached = await this.cache.getTrims(year, modelId);
if (cached) {
logger.debug('Trims retrieved from cache', { year, modelId });
return cached;
}
const trims = await this.repository.getTrims(pool, year, modelId);
await this.cache.setTrims(year, modelId, trims);
logger.debug('Trims retrieved from database and cached', { year, modelId, count: trims.length });
return trims;
} catch (error) {
logger.error('Service error: getTrims', { error, year, modelId });
throw error;
}
}
/**
* Get engines for a year, model, and trim with caching
*/
async getEngines(pool: Pool, year: number, modelId: number, trimId: number): Promise<EngineItem[]> {
try {
const cached = await this.cache.getEngines(year, modelId, trimId);
if (cached) {
logger.debug('Engines retrieved from cache', { year, modelId, trimId });
return cached;
}
const engines = await this.repository.getEngines(pool, year, modelId, trimId);
await this.cache.setEngines(year, modelId, trimId, engines);
logger.debug('Engines retrieved from database and cached', { year, modelId, trimId, count: engines.length });
return engines;
} catch (error) {
logger.error('Service error: getEngines', { error, year, modelId, trimId });
throw error;
}
}
}

View File

@@ -0,0 +1,156 @@
/**
* @ai-summary VIN decoding service with circuit breaker and fallback
* @ai-context PostgreSQL first, vPIC API fallback, Redis caching
*/
import { Pool } from 'pg';
import CircuitBreaker from 'opossum';
import { VehicleDataRepository } from '../data/vehicle-data.repository';
import { VPICClient } from '../data/vpic-client';
import { PlatformCacheService } from './platform-cache.service';
import { VINDecodeResponse, VINDecodeResult } from '../models/responses';
import { logger } from '../../../core/logging/logger';
export class VINDecodeService {
private repository: VehicleDataRepository;
private vpicClient: VPICClient;
private cache: PlatformCacheService;
private circuitBreaker: CircuitBreaker;
constructor(cache: PlatformCacheService) {
this.cache = cache;
this.repository = new VehicleDataRepository();
this.vpicClient = new VPICClient();
this.circuitBreaker = new CircuitBreaker(
async (vin: string) => this.vpicClient.decodeVIN(vin),
{
timeout: 6000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
name: 'vpic-api'
}
);
this.circuitBreaker.on('open', () => {
logger.warn('Circuit breaker opened for vPIC API');
});
this.circuitBreaker.on('halfOpen', () => {
logger.info('Circuit breaker half-open for vPIC API');
});
this.circuitBreaker.on('close', () => {
logger.info('Circuit breaker closed for vPIC API');
});
}
/**
* Validate VIN format
*/
validateVIN(vin: string): { valid: boolean; error?: string } {
if (vin.length !== 17) {
return { valid: false, error: 'VIN must be exactly 17 characters' };
}
const invalidChars = /[IOQ]/i;
if (invalidChars.test(vin)) {
return { valid: false, error: 'VIN contains invalid characters (cannot contain I, O, Q)' };
}
const validFormat = /^[A-HJ-NPR-Z0-9]{17}$/i;
if (!validFormat.test(vin)) {
return { valid: false, error: 'VIN contains invalid characters' };
}
return { valid: true };
}
/**
* Decode VIN with multi-tier strategy:
* 1. Check cache
* 2. Try PostgreSQL function
* 3. Fallback to vPIC API (with circuit breaker)
*/
async decodeVIN(pool: Pool, vin: string): Promise<VINDecodeResponse> {
const normalizedVIN = vin.toUpperCase().trim();
const validation = this.validateVIN(normalizedVIN);
if (!validation.valid) {
return {
vin: normalizedVIN,
result: null,
success: false,
error: validation.error
};
}
try {
const cached = await this.cache.getVINDecode(normalizedVIN);
if (cached) {
logger.debug('VIN decode result retrieved from cache', { vin: normalizedVIN });
return cached;
}
let result = await this.repository.decodeVIN(pool, normalizedVIN);
if (result) {
const response: VINDecodeResponse = {
vin: normalizedVIN,
result,
success: true
};
await this.cache.setVINDecode(normalizedVIN, response, true);
logger.info('VIN decoded successfully via PostgreSQL', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year });
return response;
}
logger.info('VIN not found in PostgreSQL, attempting vPIC fallback', { vin: normalizedVIN });
try {
result = await this.circuitBreaker.fire(normalizedVIN) as VINDecodeResult | null;
if (result) {
const response: VINDecodeResponse = {
vin: normalizedVIN,
result,
success: true
};
await this.cache.setVINDecode(normalizedVIN, response, true);
logger.info('VIN decoded successfully via vPIC fallback', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year });
return response;
}
} catch (circuitError) {
logger.warn('vPIC API unavailable or circuit breaker open', { vin: normalizedVIN, error: circuitError });
}
const failureResponse: VINDecodeResponse = {
vin: normalizedVIN,
result: null,
success: false,
error: 'VIN not found in database and external API unavailable'
};
await this.cache.setVINDecode(normalizedVIN, failureResponse, false);
return failureResponse;
} catch (error) {
logger.error('VIN decode error', { vin: normalizedVIN, error });
return {
vin: normalizedVIN,
result: null,
success: false,
error: 'Internal server error during VIN decoding'
};
}
}
/**
* Get circuit breaker status
*/
getCircuitBreakerStatus(): { state: string; stats: any } {
return {
state: this.circuitBreaker.opened ? 'open' : this.circuitBreaker.halfOpen ? 'half-open' : 'closed',
stats: this.circuitBreaker.stats
};
}
}

View File

@@ -0,0 +1,33 @@
/**
* @ai-summary Platform feature public API
* @ai-context Exports for feature registration
*/
import { Pool } from 'pg';
import pool from '../../core/config/database';
import { cacheService } from '../../core/config/redis';
import { VINDecodeService } from './domain/vin-decode.service';
import { PlatformCacheService } from './domain/platform-cache.service';
export { platformRoutes } from './api/platform.routes';
export { PlatformController } from './api/platform.controller';
export { VehicleDataService } from './domain/vehicle-data.service';
export { VINDecodeService } from './domain/vin-decode.service';
export { PlatformCacheService } from './domain/platform-cache.service';
export * from './models/requests';
export * from './models/responses';
// Singleton VIN decode service for use by other features
let vinDecodeServiceInstance: VINDecodeService | null = null;
export function getVINDecodeService(): VINDecodeService {
if (!vinDecodeServiceInstance) {
const platformCache = new PlatformCacheService(cacheService);
vinDecodeServiceInstance = new VINDecodeService(platformCache);
}
return vinDecodeServiceInstance;
}
// Helper to get pool for VIN decode service
export function getPool(): Pool {
return pool;
}

View File

@@ -0,0 +1,84 @@
/**
* @ai-summary Request DTOs for platform feature
* @ai-context Validation and type definitions for API requests
*/
import { z } from 'zod';
/**
* VIN validation schema
*/
export const vinDecodeRequestSchema = z.object({
vin: z.string()
.length(17, 'VIN must be exactly 17 characters')
.regex(/^[A-HJ-NPR-Z0-9]{17}$/, 'VIN contains invalid characters (cannot contain I, O, Q)')
.transform(vin => vin.toUpperCase())
});
export type VINDecodeRequest = z.infer<typeof vinDecodeRequestSchema>;
/**
* Year query parameter validation
*/
export const yearQuerySchema = z.object({
year: z.coerce.number()
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100')
});
export type YearQuery = z.infer<typeof yearQuerySchema>;
/**
* Makes query parameters validation
*/
export const makesQuerySchema = yearQuerySchema;
export type MakesQuery = z.infer<typeof makesQuerySchema>;
/**
* Models query parameters validation
*/
export const modelsQuerySchema = z.object({
year: z.coerce.number()
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
make_id: z.coerce.number()
.int('Make ID must be an integer')
.positive('Make ID must be positive')
});
export type ModelsQuery = z.infer<typeof modelsQuerySchema>;
/**
* Trims query parameters validation
*/
export const trimsQuerySchema = z.object({
year: z.coerce.number()
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
model_id: z.coerce.number()
.int('Model ID must be an integer')
.positive('Model ID must be positive')
});
export type TrimsQuery = z.infer<typeof trimsQuerySchema>;
/**
* Engines query parameters validation
*/
export const enginesQuerySchema = z.object({
year: z.coerce.number()
.int('Year must be an integer')
.min(1950, 'Year must be at least 1950')
.max(2100, 'Year must be at most 2100'),
model_id: z.coerce.number()
.int('Model ID must be an integer')
.positive('Model ID must be positive'),
trim_id: z.coerce.number()
.int('Trim ID must be an integer')
.positive('Trim ID must be positive')
});
export type EnginesQuery = z.infer<typeof enginesQuerySchema>;

View File

@@ -0,0 +1,114 @@
/**
* @ai-summary Response DTOs for platform feature
* @ai-context Type-safe response structures matching Python API
*/
/**
* Make item response
*/
export interface MakeItem {
id: number;
name: string;
}
/**
* Model item response
*/
export interface ModelItem {
id: number;
name: string;
}
/**
* Trim item response
*/
export interface TrimItem {
id: number;
name: string;
}
/**
* Engine item response
*/
export interface EngineItem {
id: number;
name: string;
}
/**
* Years response
*/
export type YearsResponse = number[];
/**
* Makes response
*/
export interface MakesResponse {
makes: MakeItem[];
}
/**
* Models response
*/
export interface ModelsResponse {
models: ModelItem[];
}
/**
* Trims response
*/
export interface TrimsResponse {
trims: TrimItem[];
}
/**
* Engines response
*/
export interface EnginesResponse {
engines: EngineItem[];
}
/**
* VIN decode result (detailed vehicle information)
*/
export interface VINDecodeResult {
make: string | null;
model: string | null;
year: number | null;
trim_name: string | null;
engine_description: string | null;
transmission_description: string | null;
horsepower: number | null;
torque: number | null;
top_speed: number | null;
fuel: string | null;
confidence_score: number | null;
vehicle_type: string | null;
}
/**
* VIN decode response (wrapper with success status)
*/
export interface VINDecodeResponse {
vin: string;
result: VINDecodeResult | null;
success: boolean;
error?: string;
}
/**
* vPIC API response structure (NHTSA)
*/
export interface VPICVariable {
Variable: string;
Value: string | null;
ValueId: string | null;
VariableId: number;
}
export interface VPICResponse {
Count: number;
Message: string;
SearchCriteria: string;
Results: VPICVariable[];
}

View File

@@ -0,0 +1,228 @@
/**
* @ai-summary Integration tests for platform feature
* @ai-context End-to-end API tests with authentication
*/
import { FastifyInstance } from 'fastify';
import { buildApp } from '../../../../app';
describe('Platform Integration Tests', () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await buildApp();
await app.ready();
authToken = 'Bearer mock-jwt-token';
});
afterAll(async () => {
await app.close();
});
describe('GET /api/platform/years', () => {
it('should return 401 without authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/years'
});
expect(response.statusCode).toBe(401);
});
it('should return list of years with authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/years',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(200);
const years = JSON.parse(response.payload);
expect(Array.isArray(years)).toBe(true);
});
});
describe('GET /api/platform/makes', () => {
it('should return 401 without authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/makes?year=2024'
});
expect(response.statusCode).toBe(401);
});
it('should return 400 for invalid year', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/makes?year=invalid',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
});
it('should return makes for valid year with authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/makes?year=2024',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.payload);
expect(data).toHaveProperty('makes');
expect(Array.isArray(data.makes)).toBe(true);
});
});
describe('GET /api/platform/vehicle', () => {
it('should return 401 without authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/vehicle?vin=1HGCM82633A123456'
});
expect(response.statusCode).toBe(401);
});
it('should return 400 for invalid VIN format', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/vehicle?vin=INVALID',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
const data = JSON.parse(response.payload);
expect(data.success).toBe(false);
expect(data.error).toContain('17 characters');
});
it('should return 400 for VIN with invalid characters', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/vehicle?vin=1HGCM82633A12345I',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
const data = JSON.parse(response.payload);
expect(data.success).toBe(false);
expect(data.error).toContain('invalid characters');
});
it('should decode valid VIN with authentication', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/vehicle?vin=1HGCM82633A123456',
headers: {
authorization: authToken
}
});
const data = JSON.parse(response.payload);
expect(data).toHaveProperty('vin');
expect(data).toHaveProperty('success');
expect(data).toHaveProperty('result');
});
});
describe('GET /api/platform/models', () => {
it('should return 400 for missing make_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/models?year=2024',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
});
it('should return models for valid year and make_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/models?year=2024&make_id=1',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.payload);
expect(data).toHaveProperty('models');
expect(Array.isArray(data.models)).toBe(true);
});
});
describe('GET /api/platform/trims', () => {
it('should return 400 for missing model_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/trims?year=2024',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
});
it('should return trims for valid year and model_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/trims?year=2024&model_id=101',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.payload);
expect(data).toHaveProperty('trims');
expect(Array.isArray(data.trims)).toBe(true);
});
});
describe('GET /api/platform/engines', () => {
it('should return 400 for missing trim_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/engines?year=2024&model_id=101',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(400);
});
it('should return engines for valid year, model_id, and trim_id', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/platform/engines?year=2024&model_id=101&trim_id=1001',
headers: {
authorization: authToken
}
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.payload);
expect(data).toHaveProperty('engines');
expect(Array.isArray(data.engines)).toBe(true);
});
});
});

View File

@@ -0,0 +1,189 @@
/**
* @ai-summary Unit tests for vehicle data service
* @ai-context Tests caching behavior for hierarchical vehicle data
*/
import { Pool } from 'pg';
import { VehicleDataService } from '../../domain/vehicle-data.service';
import { PlatformCacheService } from '../../domain/platform-cache.service';
import { VehicleDataRepository } from '../../data/vehicle-data.repository';
jest.mock('../../data/vehicle-data.repository');
jest.mock('../../domain/platform-cache.service');
describe('VehicleDataService', () => {
let service: VehicleDataService;
let mockCache: jest.Mocked<PlatformCacheService>;
let mockRepository: jest.Mocked<VehicleDataRepository>;
let mockPool: jest.Mocked<Pool>;
beforeEach(() => {
mockCache = {
getYears: jest.fn(),
setYears: jest.fn(),
getMakes: jest.fn(),
setMakes: jest.fn(),
getModels: jest.fn(),
setModels: jest.fn(),
getTrims: jest.fn(),
setTrims: jest.fn(),
getEngines: jest.fn(),
setEngines: jest.fn()
} as any;
mockRepository = {
getYears: jest.fn(),
getMakes: jest.fn(),
getModels: jest.fn(),
getTrims: jest.fn(),
getEngines: jest.fn()
} as any;
mockPool = {} as any;
service = new VehicleDataService(mockCache, mockRepository);
});
describe('getYears', () => {
it('should return cached years if available', async () => {
const mockYears = [2024, 2023, 2022];
mockCache.getYears.mockResolvedValue(mockYears);
const result = await service.getYears(mockPool);
expect(result).toEqual(mockYears);
expect(mockCache.getYears).toHaveBeenCalled();
expect(mockRepository.getYears).not.toHaveBeenCalled();
});
it('should fetch from repository and cache when cache misses', async () => {
const mockYears = [2024, 2023, 2022];
mockCache.getYears.mockResolvedValue(null);
mockRepository.getYears.mockResolvedValue(mockYears);
const result = await service.getYears(mockPool);
expect(result).toEqual(mockYears);
expect(mockRepository.getYears).toHaveBeenCalledWith(mockPool);
expect(mockCache.setYears).toHaveBeenCalledWith(mockYears);
});
});
describe('getMakes', () => {
const year = 2024;
const mockMakes = [
{ id: 1, name: 'Honda' },
{ id: 2, name: 'Toyota' }
];
it('should return cached makes if available', async () => {
mockCache.getMakes.mockResolvedValue(mockMakes);
const result = await service.getMakes(mockPool, year);
expect(result).toEqual(mockMakes);
expect(mockCache.getMakes).toHaveBeenCalledWith(year);
expect(mockRepository.getMakes).not.toHaveBeenCalled();
});
it('should fetch from repository and cache when cache misses', async () => {
mockCache.getMakes.mockResolvedValue(null);
mockRepository.getMakes.mockResolvedValue(mockMakes);
const result = await service.getMakes(mockPool, year);
expect(result).toEqual(mockMakes);
expect(mockRepository.getMakes).toHaveBeenCalledWith(mockPool, year);
expect(mockCache.setMakes).toHaveBeenCalledWith(year, mockMakes);
});
});
describe('getModels', () => {
const year = 2024;
const makeId = 1;
const mockModels = [
{ id: 101, name: 'Civic' },
{ id: 102, name: 'Accord' }
];
it('should return cached models if available', async () => {
mockCache.getModels.mockResolvedValue(mockModels);
const result = await service.getModels(mockPool, year, makeId);
expect(result).toEqual(mockModels);
expect(mockCache.getModels).toHaveBeenCalledWith(year, makeId);
expect(mockRepository.getModels).not.toHaveBeenCalled();
});
it('should fetch from repository and cache when cache misses', async () => {
mockCache.getModels.mockResolvedValue(null);
mockRepository.getModels.mockResolvedValue(mockModels);
const result = await service.getModels(mockPool, year, makeId);
expect(result).toEqual(mockModels);
expect(mockRepository.getModels).toHaveBeenCalledWith(mockPool, year, makeId);
expect(mockCache.setModels).toHaveBeenCalledWith(year, makeId, mockModels);
});
});
describe('getTrims', () => {
const year = 2024;
const modelId = 101;
const mockTrims = [
{ id: 1001, name: 'LX' },
{ id: 1002, name: 'EX' }
];
it('should return cached trims if available', async () => {
mockCache.getTrims.mockResolvedValue(mockTrims);
const result = await service.getTrims(mockPool, year, modelId);
expect(result).toEqual(mockTrims);
expect(mockCache.getTrims).toHaveBeenCalledWith(year, modelId);
expect(mockRepository.getTrims).not.toHaveBeenCalled();
});
it('should fetch from repository and cache when cache misses', async () => {
mockCache.getTrims.mockResolvedValue(null);
mockRepository.getTrims.mockResolvedValue(mockTrims);
const result = await service.getTrims(mockPool, year, modelId);
expect(result).toEqual(mockTrims);
expect(mockRepository.getTrims).toHaveBeenCalledWith(mockPool, year, modelId);
expect(mockCache.setTrims).toHaveBeenCalledWith(year, modelId, mockTrims);
});
});
describe('getEngines', () => {
const year = 2024;
const modelId = 101;
const trimId = 1001;
const mockEngines = [
{ id: 10001, name: '2.0L I4' },
{ id: 10002, name: '1.5L Turbo I4' }
];
it('should return cached engines if available', async () => {
mockCache.getEngines.mockResolvedValue(mockEngines);
const result = await service.getEngines(mockPool, year, modelId, trimId);
expect(result).toEqual(mockEngines);
expect(mockCache.getEngines).toHaveBeenCalledWith(year, modelId, trimId);
expect(mockRepository.getEngines).not.toHaveBeenCalled();
});
it('should fetch from repository and cache when cache misses', async () => {
mockCache.getEngines.mockResolvedValue(null);
mockRepository.getEngines.mockResolvedValue(mockEngines);
const result = await service.getEngines(mockPool, year, modelId, trimId);
expect(result).toEqual(mockEngines);
expect(mockRepository.getEngines).toHaveBeenCalledWith(mockPool, year, modelId, trimId);
expect(mockCache.setEngines).toHaveBeenCalledWith(year, modelId, trimId, mockEngines);
});
});
});

View File

@@ -0,0 +1,173 @@
/**
* @ai-summary Unit tests for VIN decode service
* @ai-context Tests VIN validation, PostgreSQL decode, vPIC fallback, circuit breaker
*/
import { Pool } from 'pg';
import { VINDecodeService } from '../../domain/vin-decode.service';
import { PlatformCacheService } from '../../domain/platform-cache.service';
import { VehicleDataRepository } from '../../data/vehicle-data.repository';
import { VPICClient } from '../../data/vpic-client';
jest.mock('../../data/vehicle-data.repository');
jest.mock('../../data/vpic-client');
jest.mock('../../domain/platform-cache.service');
describe('VINDecodeService', () => {
let service: VINDecodeService;
let mockCache: jest.Mocked<PlatformCacheService>;
let mockPool: jest.Mocked<Pool>;
beforeEach(() => {
mockCache = {
getVINDecode: jest.fn(),
setVINDecode: jest.fn()
} as any;
mockPool = {} as any;
service = new VINDecodeService(mockCache);
});
describe('validateVIN', () => {
it('should validate correct VIN', () => {
const result = service.validateVIN('1HGCM82633A123456');
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it('should reject VIN with incorrect length', () => {
const result = service.validateVIN('SHORT');
expect(result.valid).toBe(false);
expect(result.error).toContain('17 characters');
});
it('should reject VIN with invalid characters I, O, Q', () => {
const resultI = service.validateVIN('1HGCM82633A12345I');
const resultO = service.validateVIN('1HGCM82633A12345O');
const resultQ = service.validateVIN('1HGCM82633A12345Q');
expect(resultI.valid).toBe(false);
expect(resultO.valid).toBe(false);
expect(resultQ.valid).toBe(false);
});
it('should reject VIN with non-alphanumeric characters', () => {
const result = service.validateVIN('1HGCM82633A12345@');
expect(result.valid).toBe(false);
});
});
describe('decodeVIN', () => {
const validVIN = '1HGCM82633A123456';
const mockResult = {
make: 'Honda',
model: 'Accord',
year: 2003,
trim_name: 'LX',
engine_description: '2.4L I4',
transmission_description: '5-Speed Automatic',
horsepower: 160,
torque: 161,
top_speed: null,
fuel: 'Gasoline',
confidence_score: 0.95,
vehicle_type: 'Passenger Car'
};
it('should return cached result if available', async () => {
const cachedResponse = {
vin: validVIN,
result: mockResult,
success: true
};
mockCache.getVINDecode.mockResolvedValue(cachedResponse);
const result = await service.decodeVIN(mockPool, validVIN);
expect(result).toEqual(cachedResponse);
expect(mockCache.getVINDecode).toHaveBeenCalledWith(validVIN);
});
it('should return error for invalid VIN format', async () => {
const invalidVIN = 'INVALID';
mockCache.getVINDecode.mockResolvedValue(null);
const result = await service.decodeVIN(mockPool, invalidVIN);
expect(result.success).toBe(false);
expect(result.error).toContain('17 characters');
});
it('should uppercase and trim VIN', async () => {
const lowerVIN = ' 1hgcm82633a123456 ';
mockCache.getVINDecode.mockResolvedValue(null);
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(mockResult);
await service.decodeVIN(mockPool, lowerVIN);
expect(mockCache.getVINDecode).toHaveBeenCalledWith('1HGCM82633A123456');
});
it('should decode VIN from PostgreSQL and cache result', async () => {
mockCache.getVINDecode.mockResolvedValue(null);
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(mockResult);
const result = await service.decodeVIN(mockPool, validVIN);
expect(result.success).toBe(true);
expect(result.result).toEqual(mockResult);
expect(mockCache.setVINDecode).toHaveBeenCalledWith(
validVIN,
expect.objectContaining({ vin: validVIN, success: true }),
true
);
});
it('should fallback to vPIC when PostgreSQL returns null', async () => {
mockCache.getVINDecode.mockResolvedValue(null);
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(mockResult);
const result = await service.decodeVIN(mockPool, validVIN);
expect(result.success).toBe(true);
expect(result.result).toEqual(mockResult);
});
it('should return failure when both PostgreSQL and vPIC fail', async () => {
mockCache.getVINDecode.mockResolvedValue(null);
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(null);
const result = await service.decodeVIN(mockPool, validVIN);
expect(result.success).toBe(false);
expect(result.error).toContain('VIN not found');
});
it('should cache failed decode with shorter TTL', async () => {
mockCache.getVINDecode.mockResolvedValue(null);
(service as any).repository.decodeVIN = jest.fn().mockResolvedValue(null);
(service as any).vpicClient.decodeVIN = jest.fn().mockResolvedValue(null);
await service.decodeVIN(mockPool, validVIN);
expect(mockCache.setVINDecode).toHaveBeenCalledWith(
validVIN,
expect.objectContaining({ success: false }),
false
);
});
});
describe('getCircuitBreakerStatus', () => {
it('should return circuit breaker status', () => {
const status = service.getCircuitBreakerStatus();
expect(status).toHaveProperty('state');
expect(status).toHaveProperty('stats');
expect(['open', 'half-open', 'closed']).toContain(status.state);
});
});
});