MVP Build

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

View File

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

View File

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

View File

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