Possible working ETL

This commit is contained in:
Eric Gullickson
2025-12-15 18:19:55 -06:00
parent 1fc69b7779
commit 1e599e334f
110 changed files with 4843 additions and 2078706 deletions

View File

@@ -5,7 +5,6 @@
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 {
@@ -13,21 +12,18 @@ import {
ModelsQuery,
TrimsQuery,
EnginesQuery,
TransmissionsQuery,
VINDecodeRequest
TransmissionsQuery
} 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);
}
/**
@@ -100,12 +96,14 @@ export class PlatformController {
}
/**
* GET /api/platform/transmissions?year={year}&make={make}&model={model}
* GET /api/platform/transmissions?year={year}&make={make}&model={model}&trim={trim}[&engine={engine}]
*/
async getTransmissions(request: FastifyRequest<{ Querystring: TransmissionsQuery }>, reply: FastifyReply): Promise<void> {
try {
const { year, make, model } = request.query as any;
const transmissions = await this.vehicleDataService.getTransmissions(this.pool, year, make, model);
const { year, make, model, trim, engine } = request.query as any;
const transmissions = engine
? await this.vehicleDataService.getTransmissionsForTrimAndEngine(this.pool, year, make, model, trim, engine)
: await this.vehicleDataService.getTransmissionsForTrim(this.pool, year, make, model, trim);
reply.code(200).send({ transmissions });
} catch (error) {
logger.error('Controller error: getTransmissions', { error, query: request.query });
@@ -113,34 +111,4 @@ export class PlatformController {
}
}
/**
* 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

@@ -10,8 +10,7 @@ import {
ModelsQuery,
TrimsQuery,
EnginesQuery,
TransmissionsQuery,
VINDecodeRequest
TransmissionsQuery
} from '../models/requests';
import pool from '../../../core/config/database';
@@ -41,10 +40,6 @@ async function platformRoutes(fastify: FastifyInstance) {
fastify.get<{ Querystring: TransmissionsQuery }>('/platform/transmissions', {
preHandler: [fastify.authenticate]
}, controller.getTransmissions.bind(controller));
fastify.get<{ Querystring: VINDecodeRequest }>('/platform/vehicle', {
preHandler: [fastify.authenticate]
}, controller.decodeVIN.bind(controller));
}
export default fastifyPlugin(platformRoutes);

View File

@@ -84,18 +84,7 @@ export class VehicleDataRepository {
*/
async getEngines(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
const query = `
SELECT DISTINCT
CASE
WHEN vo.engine_id IS NULL THEN 'N/A (Electric)'
ELSE e.name
END as engine_name
FROM vehicle_options vo
LEFT JOIN engines e ON e.id = vo.engine_id
WHERE vo.year = $1
AND vo.make = $2
AND vo.model = $3
AND vo.trim = $4
ORDER BY engine_name
SELECT engine_name FROM get_engines_for_vehicle($1, $2, $3, $4)
`;
try {
@@ -108,30 +97,46 @@ export class VehicleDataRepository {
}
/**
* Get transmissions for a specific year, make, and model
* Get transmissions for a specific year, make, model, and trim
* Returns real transmission types from the database (not hardcoded)
*/
async getTransmissions(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
async getTransmissionsForTrim(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
const query = `
SELECT DISTINCT
CASE
WHEN vo.transmission_id IS NULL THEN 'N/A'
ELSE t.type
END as transmission_type
FROM vehicle_options vo
LEFT JOIN transmissions t ON t.id = vo.transmission_id
WHERE vo.year = $1
AND vo.make = $2
AND vo.model = $3
ORDER BY transmission_type
SELECT transmission_type FROM get_transmissions_for_vehicle($1, $2, $3, $4)
`;
try {
const result = await pool.query(query, [year, make, model]);
const result = await pool.query(query, [year, make, model, trim]);
return result.rows.map(row => row.transmission_type);
} catch (error) {
logger.error('Repository error: getTransmissions', { error, year, make, model });
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}`);
logger.error('Repository error: getTransmissionsForTrim', { error, year, make, model, trim });
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}, trim ${trim}`);
}
}
async getTransmissionsForTrimAndEngine(pool: Pool, year: number, make: string, model: string, trim: string, engine: string): Promise<string[]> {
const query = `
SELECT transmission_type FROM get_transmissions_for_vehicle_engine($1, $2, $3, $4, $5)
`;
try {
const result = await pool.query(query, [year, make, model, trim, engine]);
return result.rows.map(row => row.transmission_type);
} catch (error) {
logger.error('Repository error: getTransmissionsForTrimAndEngine', { error, year, make, model, trim, engine });
throw new Error(`Failed to retrieve transmissions for year ${year}, make ${make}, model ${model}, trim ${trim}, engine ${engine}`);
}
}
async getEnginesForTrimAndTransmission(pool: Pool, year: number, make: string, model: string, trim: string, transmission: string): Promise<string[]> {
const query = `
SELECT engine_name FROM get_engines_for_vehicle_trans($1, $2, $3, $4, $5)
`;
try {
const result = await pool.query(query, [year, make, model, trim, transmission]);
return result.rows.map(row => row.engine_name);
} catch (error) {
logger.error('Repository error: getEnginesForTrimAndTransmission', { error, year, make, model, trim, transmission });
throw new Error(`Failed to retrieve engines for year ${year}, make ${make}, model ${model}, trim ${trim}, transmission ${transmission}`);
}
}

View File

@@ -1,125 +0,0 @@
/**
* @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

@@ -94,21 +94,41 @@ export class PlatformCacheService {
}
/**
* Get cached transmissions for year, make, and model
* Get cached transmissions for year, make, model, and trim
*/
async getTransmissions(year: number, make: string, model: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model;
async getTransmissionsForTrim(year: number, make: string, model: string, trim: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim;
return await this.cacheService.get<string[]>(key);
}
/**
* Set cached transmissions for year, make, and model
* Set cached transmissions for year, make, model, and trim
*/
async setTransmissions(year: number, make: string, model: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model;
async setTransmissionsForTrim(year: number, make: string, model: string, trim: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim;
await this.cacheService.set(key, transmissions, ttl);
}
async getTransmissionsForTrimAndEngine(year: number, make: string, model: string, trim: string, engine: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim + ':engine:' + engine;
return await this.cacheService.get<string[]>(key);
}
async setTransmissionsForTrimAndEngine(year: number, make: string, model: string, trim: string, engine: string, transmissions: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:transmissions:' + year + ':' + make + ':' + model + ':' + trim + ':engine:' + engine;
await this.cacheService.set(key, transmissions, ttl);
}
async getEnginesForTrimAndTransmission(year: number, make: string, model: string, trim: string, transmission: string): Promise<string[] | null> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim + ':transmission:' + transmission;
return await this.cacheService.get<string[]>(key);
}
async setEnginesForTrimAndTransmission(year: number, make: string, model: string, trim: string, transmission: string, engines: string[], ttl: number = 6 * 3600): Promise<void> {
const key = this.prefix + 'vehicle-data:engines:' + year + ':' + make + ':' + model + ':' + trim + ':transmission:' + transmission;
await this.cacheService.set(key, engines, ttl);
}
/**
* Get cached VIN decode result
*/

View File

@@ -123,23 +123,94 @@ export class VehicleDataService {
}
/**
* Get transmissions for a year, make, and model with caching
* Get transmissions for a year, make, model, and trim with caching
*/
async getTransmissions(pool: Pool, year: number, make: string, model: string): Promise<string[]> {
async getTransmissionsForTrim(pool: Pool, year: number, make: string, model: string, trim: string): Promise<string[]> {
try {
const cached = await this.cache.getTransmissions(year, make, model);
const cached = await this.cache.getTransmissionsForTrim(year, make, model, trim);
if (cached) {
logger.debug('Transmissions retrieved from cache', { year, make, model });
logger.debug('Transmissions retrieved from cache', { year, make, model, trim });
return cached;
}
const transmissions = await this.repository.getTransmissions(pool, year, make, model);
await this.cache.setTransmissions(year, make, model, transmissions);
logger.debug('Transmissions retrieved from database and cached', { year, make, model, count: transmissions.length });
const transmissions = await this.repository.getTransmissionsForTrim(pool, year, make, model, trim);
await this.cache.setTransmissionsForTrim(year, make, model, trim, transmissions);
logger.debug('Transmissions retrieved from database and cached', { year, make, model, trim, count: transmissions.length });
return transmissions;
} catch (error) {
logger.error('Service error: getTransmissions', { error, year, make, model });
logger.error('Service error: getTransmissionsForTrim', { error, year, make, model, trim });
throw error;
}
}
async getTransmissionsForTrimAndEngine(
pool: Pool,
year: number,
make: string,
model: string,
trim: string,
engine: string
): Promise<string[]> {
try {
const cached = await this.cache.getTransmissionsForTrimAndEngine(year, make, model, trim, engine);
if (cached) {
logger.debug('Transmissions (engine-filtered) retrieved from cache', { year, make, model, trim, engine });
return cached;
}
const transmissions = await this.repository.getTransmissionsForTrimAndEngine(pool, year, make, model, trim, engine);
await this.cache.setTransmissionsForTrimAndEngine(year, make, model, trim, engine, transmissions);
logger.debug('Transmissions (engine-filtered) retrieved from database and cached', { year, make, model, trim, engine, count: transmissions.length });
return transmissions;
} catch (error) {
logger.error('Service error: getTransmissionsForTrimAndEngine', { error, year, make, model, trim, engine });
throw error;
}
}
async getEnginesForTrimAndTransmission(
pool: Pool,
year: number,
make: string,
model: string,
trim: string,
transmission: string
): Promise<string[]> {
try {
const cached = await this.cache.getEnginesForTrimAndTransmission(year, make, model, trim, transmission);
if (cached) {
logger.debug('Engines (transmission-filtered) retrieved from cache', { year, make, model, trim, transmission });
return cached;
}
const engines = await this.repository.getEnginesForTrimAndTransmission(pool, year, make, model, trim, transmission);
await this.cache.setEnginesForTrimAndTransmission(year, make, model, trim, transmission, engines);
logger.debug('Engines (transmission-filtered) retrieved from database and cached', { year, make, model, trim, transmission, count: engines.length });
return engines;
} catch (error) {
logger.error('Service error: getEnginesForTrimAndTransmission', { error, year, make, model, trim, transmission });
throw error;
}
}
async getOptions(
pool: Pool,
year: number,
make: string,
model: string,
trim: string,
engine?: string,
transmission?: string
): Promise<{ engines: string[]; transmissions: string[] }> {
const [engines, transmissions] = await Promise.all([
transmission
? this.getEnginesForTrimAndTransmission(pool, year, make, model, trim, transmission)
: this.getEngines(pool, year, make, model, trim),
engine
? this.getTransmissionsForTrimAndEngine(pool, year, make, model, trim, engine)
: this.getTransmissionsForTrim(pool, year, make, model, trim)
]);
return { engines, transmissions };
}
}

View File

@@ -1,156 +0,0 @@
/**
* @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

@@ -5,30 +5,19 @@
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';
import { VehicleDataService } from './domain/vehicle-data.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;
// Singleton vehicle data service for use by other features
let vehicleDataServiceInstance: VehicleDataService | null = null;
export function getVINDecodeService(): VINDecodeService {
if (!vinDecodeServiceInstance) {
const platformCache = new PlatformCacheService(cacheService);
vinDecodeServiceInstance = new VINDecodeService(platformCache);
}
return vinDecodeServiceInstance;
}
export function getVehicleDataService(): VehicleDataService {
if (!vehicleDataServiceInstance) {
const platformCache = new PlatformCacheService(cacheService);

View File

@@ -102,7 +102,11 @@ export const transmissionsQuerySchema = z.object({
.max(100, 'Make must be less than 100 characters'),
model: z.string()
.min(1, 'Model is required')
.max(100, 'Model must be less than 100 characters')
.max(100, 'Model must be less than 100 characters'),
trim: z.string()
.min(1, 'Trim is required')
.max(100, 'Trim must be less than 100 characters'),
engine: z.string().optional()
});
export type TransmissionsQuery = z.infer<typeof transmissionsQuerySchema>;

View File

@@ -1,173 +0,0 @@
/**
* @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);
});
});
});