|
|
|
|
@@ -91,32 +91,32 @@ describe('VehiclesService', () => {
|
|
|
|
|
it('retrieves models scoped to year and make', async () => {
|
|
|
|
|
vehicleDataServiceMock.getModels.mockResolvedValue([{ id: 20, name: 'Civic' }]);
|
|
|
|
|
|
|
|
|
|
const result = await service.getDropdownModels(2024, 10);
|
|
|
|
|
const result = await service.getDropdownModels(2024, 'Honda');
|
|
|
|
|
|
|
|
|
|
expect(vehicleDataServiceMock.getModels).toHaveBeenCalledWith('mock-pool', 2024, 10);
|
|
|
|
|
expect(vehicleDataServiceMock.getModels).toHaveBeenCalledWith('mock-pool', 2024, 'Honda');
|
|
|
|
|
expect(result).toEqual([{ id: 20, name: 'Civic' }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('retrieves trims scoped to year, make, and model', async () => {
|
|
|
|
|
vehicleDataServiceMock.getTrims.mockResolvedValue([{ id: 30, name: 'Sport' }]);
|
|
|
|
|
|
|
|
|
|
const result = await service.getDropdownTrims(2024, 10, 20);
|
|
|
|
|
const result = await service.getDropdownTrims(2024, 'Honda', 'Civic');
|
|
|
|
|
|
|
|
|
|
expect(vehicleDataServiceMock.getTrims).toHaveBeenCalledWith('mock-pool', 2024, 20);
|
|
|
|
|
expect(vehicleDataServiceMock.getTrims).toHaveBeenCalledWith('mock-pool', 2024, 'Honda', 'Civic');
|
|
|
|
|
expect(result).toEqual([{ id: 30, name: 'Sport' }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('retrieves engines scoped to selection', async () => {
|
|
|
|
|
vehicleDataServiceMock.getEngines.mockResolvedValue([{ id: 40, name: '2.0L Turbo' }]);
|
|
|
|
|
|
|
|
|
|
const result = await service.getDropdownEngines(2024, 10, 20, 30);
|
|
|
|
|
const result = await service.getDropdownEngines(2024, 'Honda', 'Civic', 'Sport');
|
|
|
|
|
|
|
|
|
|
expect(vehicleDataServiceMock.getEngines).toHaveBeenCalledWith('mock-pool', 2024, 20, 30);
|
|
|
|
|
expect(vehicleDataServiceMock.getEngines).toHaveBeenCalledWith('mock-pool', 2024, 'Honda', 'Civic', 'Sport');
|
|
|
|
|
expect(result).toEqual([{ id: 40, name: '2.0L Turbo' }]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns static transmission options', async () => {
|
|
|
|
|
const result = await service.getDropdownTransmissions(2024, 10, 20);
|
|
|
|
|
const result = await service.getDropdownTransmissions(2024, 'Honda', 'Civic', 'Sport');
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual([
|
|
|
|
|
{ id: 1, name: 'Automatic' },
|
|
|
|
|
@@ -355,4 +355,237 @@ describe('VehiclesService', () => {
|
|
|
|
|
await expect(service.deleteVehicle('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getTCO', () => {
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
vin: '1HGBH41JXMN109186',
|
|
|
|
|
make: 'Honda',
|
|
|
|
|
model: 'Civic',
|
|
|
|
|
year: 2021,
|
|
|
|
|
odometerReading: 50000,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 25000,
|
|
|
|
|
purchaseDate: '2022-01-15',
|
|
|
|
|
insuranceCost: 150,
|
|
|
|
|
insuranceInterval: 'monthly' as const,
|
|
|
|
|
registrationCost: 200,
|
|
|
|
|
registrationInterval: 'annual' as const,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
|
|
|
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('should throw error if vehicle not found', async () => {
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
await expect(service.getTCO('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.getTCO('vehicle-id-123', 'user-123')).rejects.toThrow('Unauthorized');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return TCO with all cost components', async () => {
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
expect(result.vehicleId).toBe('vehicle-id-123');
|
|
|
|
|
expect(result.purchasePrice).toBe(25000);
|
|
|
|
|
expect(result.lifetimeTotal).toBeGreaterThan(25000);
|
|
|
|
|
expect(result.distanceUnit).toBeDefined();
|
|
|
|
|
expect(result.currencyCode).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle missing optional TCO fields gracefully', async () => {
|
|
|
|
|
const vehicleWithoutTCO = {
|
|
|
|
|
...mockVehicle,
|
|
|
|
|
purchasePrice: undefined,
|
|
|
|
|
purchaseDate: undefined,
|
|
|
|
|
insuranceCost: undefined,
|
|
|
|
|
insuranceInterval: undefined,
|
|
|
|
|
registrationCost: undefined,
|
|
|
|
|
registrationInterval: undefined,
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(vehicleWithoutTCO);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
expect(result.purchasePrice).toBe(0);
|
|
|
|
|
expect(result.insuranceCosts).toBe(0);
|
|
|
|
|
expect(result.registrationCosts).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return zero costPerDistance when odometer is zero', async () => {
|
|
|
|
|
const vehicleWithZeroOdometer = { ...mockVehicle, odometerReading: 0 };
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(vehicleWithZeroOdometer);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
expect(result.costPerDistance).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should calculate costPerDistance correctly', async () => {
|
|
|
|
|
const vehicleWith100Miles = {
|
|
|
|
|
...mockVehicle,
|
|
|
|
|
odometerReading: 100,
|
|
|
|
|
purchasePrice: 1000,
|
|
|
|
|
insuranceCost: undefined,
|
|
|
|
|
registrationCost: undefined,
|
|
|
|
|
purchaseDate: undefined,
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(vehicleWith100Miles);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
// With only $1000 purchase price and 100 miles, costPerDistance should be ~$10/mile
|
|
|
|
|
// (plus any fuel/maintenance costs which may be 0 in mock)
|
|
|
|
|
expect(result.costPerDistance).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('normalizeRecurringCost (via getTCO)', () => {
|
|
|
|
|
it('should normalize monthly costs correctly', async () => {
|
|
|
|
|
// Vehicle purchased 12 months ago with $100/month insurance
|
|
|
|
|
const oneYearAgo = new Date();
|
|
|
|
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
|
|
|
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
odometerReading: 10000,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 0,
|
|
|
|
|
purchaseDate: oneYearAgo.toISOString().split('T')[0],
|
|
|
|
|
insuranceCost: 100,
|
|
|
|
|
insuranceInterval: 'monthly' as const,
|
|
|
|
|
registrationCost: 0,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
// 12 months * $100/month = $1200 insurance
|
|
|
|
|
expect(result.insuranceCosts).toBeCloseTo(1200, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should normalize annual costs correctly', async () => {
|
|
|
|
|
// Vehicle purchased 24 months ago with $200/year registration
|
|
|
|
|
const twoYearsAgo = new Date();
|
|
|
|
|
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
|
|
|
|
|
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
odometerReading: 20000,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 0,
|
|
|
|
|
purchaseDate: twoYearsAgo.toISOString().split('T')[0],
|
|
|
|
|
insuranceCost: 0,
|
|
|
|
|
registrationCost: 200,
|
|
|
|
|
registrationInterval: 'annual' as const,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
// 2 years * $200/year = $400 registration
|
|
|
|
|
expect(result.registrationCosts).toBeCloseTo(400, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle semi-annual costs correctly', async () => {
|
|
|
|
|
// Vehicle purchased 12 months ago with $300/6months insurance
|
|
|
|
|
const oneYearAgo = new Date();
|
|
|
|
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
|
|
|
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
odometerReading: 10000,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 0,
|
|
|
|
|
purchaseDate: oneYearAgo.toISOString().split('T')[0],
|
|
|
|
|
insuranceCost: 300,
|
|
|
|
|
insuranceInterval: 'semi_annual' as const,
|
|
|
|
|
registrationCost: 0,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
// 12 months / 12 * 2 payments/year * $300 = $600 insurance
|
|
|
|
|
expect(result.insuranceCosts).toBeCloseTo(600, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should guard against division by zero with new purchase', async () => {
|
|
|
|
|
// Vehicle purchased today
|
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
odometerReading: 0,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 30000,
|
|
|
|
|
purchaseDate: today,
|
|
|
|
|
insuranceCost: 100,
|
|
|
|
|
insuranceInterval: 'monthly' as const,
|
|
|
|
|
registrationCost: 0,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
// Should not throw - Math.max(1, monthsOwned) should prevent division by zero
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
expect(result.lifetimeTotal).toBeGreaterThanOrEqual(30000);
|
|
|
|
|
// Insurance should be calculated for at least 1 month
|
|
|
|
|
expect(result.insuranceCosts).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle future purchase date gracefully', async () => {
|
|
|
|
|
// Vehicle with purchase date in the future (should treat as 0 months)
|
|
|
|
|
const futureDate = new Date();
|
|
|
|
|
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
|
|
|
|
|
|
|
|
const mockVehicle = {
|
|
|
|
|
id: 'vehicle-id-123',
|
|
|
|
|
userId: 'user-123',
|
|
|
|
|
odometerReading: 0,
|
|
|
|
|
isActive: true,
|
|
|
|
|
purchasePrice: 30000,
|
|
|
|
|
purchaseDate: futureDate.toISOString().split('T')[0],
|
|
|
|
|
insuranceCost: 100,
|
|
|
|
|
insuranceInterval: 'monthly' as const,
|
|
|
|
|
registrationCost: 0,
|
|
|
|
|
tcoEnabled: true,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
repositoryInstance.findById.mockResolvedValue(mockVehicle);
|
|
|
|
|
|
|
|
|
|
// Should not throw
|
|
|
|
|
const result = await service.getTCO('vehicle-id-123', 'user-123');
|
|
|
|
|
|
|
|
|
|
expect(result.purchasePrice).toBe(30000);
|
|
|
|
|
// With future date, monthsOwned returns 0, but Math.max(1, 0) = 1
|
|
|
|
|
// so insurance should still calculate for minimum 1 payment period
|
|
|
|
|
expect(result.insuranceCosts).toBeGreaterThanOrEqual(0);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|