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

@@ -12,6 +12,8 @@ import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/v
export class VehiclesController {
private vehiclesService: VehiclesService;
private static readonly MIN_YEAR = 2017;
private static readonly MAX_YEAR = 2022;
constructor() {
const repository = new VehiclesRepository(pool);
@@ -153,10 +155,10 @@ export class VehiclesController {
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
try {
const { year } = request.query;
if (!year || year < 1980 || year > new Date().getFullYear() + 1) {
if (!year || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year parameter is required (1980-' + (new Date().getFullYear() + 1) + ')'
message: `Valid year parameter is required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
@@ -174,10 +176,10 @@ export class VehiclesController {
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
try {
const { year, make } = request.query;
if (!year || !make || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0) {
if (!year || !make || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year and make parameters are required'
message: `Valid year and make parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
@@ -192,20 +194,20 @@ export class VehiclesController {
}
}
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try {
const { year, make, model } = request.query;
if (!year || !make || !model || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0) {
const { year, make, model, trim } = request.query;
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, and model parameters are required'
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make, model);
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make, model, trim);
return reply.code(200).send(transmissions);
} catch (error) {
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model });
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model, trim: request.query?.trim });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get transmissions'
@@ -216,10 +218,10 @@ export class VehiclesController {
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try {
const { year, make, model, trim } = request.query;
if (!year || !make || !model || !trim || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, model, and trim parameters are required'
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
@@ -237,10 +239,10 @@ export class VehiclesController {
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
try {
const { year, make, model } = request.query;
if (!year || !make || !model || year < 1980 || year > new Date().getFullYear() + 1 || make.trim().length === 0 || model.trim().length === 0) {
if (!year || !make || !model || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, and model parameters are required'
message: `Valid year, make, and model parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
@@ -269,26 +271,23 @@ export class VehiclesController {
}
}
async decodeVIN(request: FastifyRequest<{ Body: { vin: string } }>, reply: FastifyReply) {
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
try {
const { vin } = request.body;
if (!vin || vin.length !== 17) {
const { year, make, model, trim, engine, transmission } = request.query;
if (!year || !make || !model || !trim || year < VehiclesController.MIN_YEAR || year > VehiclesController.MAX_YEAR || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
vin: vin || '',
success: false,
error: 'VIN must be exactly 17 characters'
error: 'Bad Request',
message: `Valid year, make, model, and trim parameters are required (${VehiclesController.MIN_YEAR}-${VehiclesController.MAX_YEAR})`
});
}
const result = await this.vehiclesService.decodeVIN(vin);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error decoding VIN', { error, vin: request.body?.vin });
const options = await this.vehiclesService.getDropdownOptions(year, make, model, trim, engine, transmission);
return reply.code(200).send(options);
} catch (error) {
logger.error('Error getting dropdown options', { error, query: request.query });
return reply.code(500).send({
vin: request.body?.vin || '',
success: false,
error: 'VIN decode failed'
error: 'Internal server error',
message: 'Failed to get engine/transmission options'
});
}
}

View File

@@ -63,16 +63,16 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
handler: vehiclesController.getDropdownEngines.bind(vehiclesController)
});
// GET /api/vehicles/dropdown/transmissions?year=2024&make=Ford&model=F-150 - Get transmissions (Level 3)
fastify.get<{ Querystring: { year: number; make: string; model: string } }>('/vehicles/dropdown/transmissions', {
// GET /api/vehicles/dropdown/transmissions?year=2024&make=Ford&model=F-150&trim=XLT - Get transmissions (Level 4, trim-filtered)
fastify.get<{ Querystring: { year: number; make: string; model: string; trim: string } }>('/vehicles/dropdown/transmissions', {
preHandler: [fastify.authenticate],
handler: vehiclesController.getDropdownTransmissions.bind(vehiclesController)
});
// POST /api/vehicles/decode-vin - Decode VIN and return vehicle information
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
// GET /api/vehicles/dropdown/options?year&make&model&trim[&engine=...][&transmission=...] - Pair-safe options for engine/transmission
fastify.get<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>('/vehicles/dropdown/options', {
preHandler: [fastify.authenticate],
handler: vehiclesController.decodeVIN.bind(vehiclesController)
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
});
// Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown"

View File

@@ -4,7 +4,6 @@
*/
import { VehiclesRepository } from '../data/vehicles.repository';
import { getVINDecodeService, getVehicleDataService, getPool } from '../../platform';
import {
Vehicle,
CreateVehicleRequest,
@@ -15,6 +14,7 @@ import { logger } from '../../../core/logging/logger';
import { cacheService } from '../../../core/config/redis';
import { isValidVIN } from '../../../shared-minimal/utils/validators';
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
import { getVehicleDataService, getPool } from '../../platform';
export class VehiclesService {
private readonly cachePrefix = 'vehicles';
@@ -27,10 +27,6 @@ export class VehiclesService {
async createVehicle(data: CreateVehicleRequest, userId: string): Promise<VehicleResponse> {
logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: (data as any).licensePlate });
let make: string | undefined;
let model: string | undefined;
let year: number | undefined;
if (data.vin) {
// Validate VIN if provided
if (!isValidVIN(data.vin)) {
@@ -41,33 +37,19 @@ export class VehiclesService {
if (existing) {
throw new Error('Vehicle with this VIN already exists');
}
// Attempt VIN decode to enrich fields using platform service
const vinDecodeService = getVINDecodeService();
const pool = getPool();
const vinDecodeResult = await vinDecodeService.decodeVIN(pool, data.vin);
if (vinDecodeResult.success && vinDecodeResult.result) {
make = normalizeMakeName(vinDecodeResult.result.make);
model = normalizeModelName(vinDecodeResult.result.model);
year = vinDecodeResult.result.year ?? undefined;
// VIN caching is now handled by platform feature
}
}
// Create vehicle (VIN optional). Client-sent make/model/year override decode if provided.
const inputMake = (data as any).make ?? make;
const inputModel = (data as any).model ?? model;
// Create vehicle with user-provided data
const vehicle = await this.repository.create({
...data,
userId,
make: normalizeMakeName(inputMake),
model: normalizeModelName(inputModel),
year: (data as any).year ?? year,
make: data.make ? normalizeMakeName(data.make) : undefined,
model: data.model ? normalizeModelName(data.model) : undefined,
});
// Invalidate user's vehicle list cache
await this.invalidateUserCache(userId);
return this.toResponse(vehicle);
}
@@ -178,12 +160,12 @@ export class VehiclesService {
return vehicleDataService.getModels(pool, year, make);
}
async getDropdownTransmissions(year: number, make: string, model: string): Promise<string[]> {
async getDropdownTransmissions(year: number, make: string, model: string, trim: string): Promise<string[]> {
const vehicleDataService = getVehicleDataService();
const pool = getPool();
logger.info('Fetching dropdown transmissions via platform module', { year, make, model });
return vehicleDataService.getTransmissions(pool, year, make, model);
logger.info('Fetching dropdown transmissions via platform module', { year, make, model, trim });
return vehicleDataService.getTransmissionsForTrim(pool, year, make, model, trim);
}
async getDropdownEngines(year: number, make: string, model: string, trim: string): Promise<string[]> {
@@ -202,6 +184,21 @@ export class VehiclesService {
return vehicleDataService.getTrims(pool, year, make, model);
}
async getDropdownOptions(
year: number,
make: string,
model: string,
trim: string,
engine?: string,
transmission?: string
): Promise<{ engines: string[]; transmissions: string[] }> {
const vehicleDataService = getVehicleDataService();
const pool = getPool();
logger.info('Fetching dropdown options via platform module', { year, make, model, trim, engine, transmission });
return vehicleDataService.getOptions(pool, year, make, model, trim, engine, transmission);
}
async getDropdownYears(): Promise<number[]> {
const vehicleDataService = getVehicleDataService();
const pool = getPool();
@@ -210,54 +207,6 @@ export class VehiclesService {
return vehicleDataService.getYears(pool);
}
async decodeVIN(vin: string): Promise<{
vin: string;
success: boolean;
year?: number;
make?: string;
model?: string;
trimLevel?: string;
engine?: string;
transmission?: string;
confidence?: number;
error?: string;
}> {
try {
logger.info('Decoding VIN', { vin });
// Use platform feature's VIN decode service
const vinDecodeService = getVINDecodeService();
const pool = getPool();
const result = await vinDecodeService.decodeVIN(pool, vin);
if (result.success && result.result) {
return {
vin,
success: true,
year: result.result.year ?? undefined,
make: result.result.make ?? undefined,
model: result.result.model ?? undefined,
trimLevel: result.result.trim_name ?? undefined,
engine: result.result.engine_description ?? undefined,
confidence: 85 // High confidence since we have good data
};
} else {
return {
vin,
success: false,
error: result.error || 'Unable to decode VIN'
};
}
} catch (error) {
logger.error('Failed to decode VIN', { vin, error });
return {
vin,
success: false,
error: 'VIN decode service unavailable'
};
}
}
private toResponse(vehicle: Vehicle): VehicleResponse {
return {
id: vehicle.id,

View File

@@ -22,19 +22,6 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
};
});
// 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 () => {
@@ -67,7 +54,7 @@ describe('Vehicles Integration Tests', () => {
});
describe('POST /api/vehicles', () => {
it('should create a new vehicle', async () => {
it('should create a new vehicle with VIN', async () => {
const vehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'My Test Car',
@@ -84,9 +71,6 @@ describe('Vehicles Integration Tests', () => {
id: expect.any(String),
userId: 'test-user-123',
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021,
nickname: 'My Test Car',
color: 'Blue',
odometerReading: 50000,
@@ -113,7 +97,8 @@ describe('Vehicles Integration Tests', () => {
it('should reject duplicate VIN for same user', async () => {
const vehicleData = {
vin: '1HGBH41JXMN109186',
nickname: 'First Car'
nickname: 'First Car',
licensePlate: 'ABC123'
};
// Create first vehicle
@@ -128,7 +113,7 @@ describe('Vehicles Integration Tests', () => {
.send({ ...vehicleData, nickname: 'Duplicate Car' })
.expect(400);
expect(response.body.error).toBe('Vehicle with this VIN already exists');
expect(response.body.message).toContain('already exists');
});
});

View File

@@ -12,14 +12,12 @@ import * as platformModule from '../../../platform';
jest.mock('../../data/vehicles.repository');
jest.mock('../../../../core/config/redis');
jest.mock('../../../platform', () => ({
getVINDecodeService: jest.fn(),
getVehicleDataService: jest.fn(),
getPool: jest.fn()
}));
const mockRepository = jest.mocked(VehiclesRepository);
const mockCacheService = jest.mocked(cacheService);
const mockGetVINDecodeService = jest.mocked(platformModule.getVINDecodeService);
const mockGetVehicleDataService = jest.mocked(platformModule.getVehicleDataService);
const mockGetPool = jest.mocked(platformModule.getPool);
@@ -117,7 +115,7 @@ describe('VehiclesService', () => {
]);
});
});
});
describe('createVehicle', () => {
const mockVehicleData = {
vin: '1HGBH41JXMN109186',
@@ -126,22 +124,13 @@ describe('VehiclesService', () => {
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,
make: undefined,
model: undefined,
year: undefined,
nickname: 'My Car',
color: 'Blue',
licensePlate: undefined,
@@ -152,20 +141,7 @@ describe('VehiclesService', () => {
updatedAt: new Date('2024-01-01T00:00:00Z'),
};
it('should create a vehicle with VIN decoding', async () => {
const mockVinDecodeService = {
decodeVIN: jest.fn().mockResolvedValue({
success: true,
data: {
vin: '1HGBH41JXMN109186',
make: 'Honda',
model: 'Civic',
year: 2021
}
})
};
mockGetVINDecodeService.mockReturnValue(mockVinDecodeService as any);
it('should create a vehicle with user-provided VIN', async () => {
repositoryInstance.findByUserAndVIN.mockResolvedValue(null);
repositoryInstance.create.mockResolvedValue(mockCreatedVehicle);
mockCacheService.del.mockResolvedValue(undefined);
@@ -173,16 +149,13 @@ describe('VehiclesService', () => {
const result = await service.createVehicle(mockVehicleData, 'user-123');
expect(repositoryInstance.findByUserAndVIN).toHaveBeenCalledWith('user-123', '1HGBH41JXMN109186');
expect(mockVinDecodeService.decodeVIN).toHaveBeenCalledWith('mock-pool', '1HGBH41JXMN109186');
expect(repositoryInstance.create).toHaveBeenCalledWith({
...mockVehicleData,
userId: 'user-123',
make: 'Honda',
model: 'Civic',
year: 2021,
make: undefined,
model: undefined,
});
expect(result.id).toBe('vehicle-id-123');
expect(result.make).toBe('Honda');
});
it('should reject invalid VIN format', async () => {
@@ -196,31 +169,6 @@ describe('VehiclesService', () => {
await expect(service.createVehicle(mockVehicleData, 'user-123')).rejects.toThrow('Vehicle with this VIN already exists');
});
it('should handle VIN decode failure gracefully', async () => {
const mockVinDecodeService = {
decodeVIN: jest.fn().mockResolvedValue({
success: false,
error: 'VIN decode failed'
})
};
mockGetVINDecodeService.mockReturnValue(mockVinDecodeService as any);
repositoryInstance.findByUserAndVIN.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', () => {