/** * @ai-summary Integration tests for vehicles API endpoints * @ai-context Tests complete request/response cycle with test database */ import request from 'supertest'; import pool from '../../../../core/config/database'; import { cacheService } from '../../../../core/config/redis'; import { readFileSync } from 'fs'; import { join } from 'path'; import fastifyPlugin from 'fastify-plugin'; // Mock auth plugin to bypass JWT validation in tests jest.mock('../../../../core/plugins/auth.plugin', () => { const fp = require('fastify-plugin'); return { default: fp(async function(fastify: any) { fastify.decorate('authenticate', async function(request: any, _reply: any) { request.user = { sub: 'test-user-123' }; }); }, { name: 'auth-plugin' }) }; }); // Import app after mocking import app from '../../../../app'; 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 with VIN', 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', 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', licensePlate: 'ABC123' }; // 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.message).toContain('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'); }); }); describe('Vehicle Limit Enforcement', () => { beforeEach(async () => { // Ensure user_profiles table exists and has test user await pool.query(` CREATE TABLE IF NOT EXISTS user_profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), auth0_sub VARCHAR(255) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL, display_name VARCHAR(255), subscription_tier VARCHAR(50) NOT NULL DEFAULT 'free', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Create or update test user with free tier await pool.query(` INSERT INTO user_profiles (auth0_sub, email, subscription_tier) VALUES ($1, $2, $3) ON CONFLICT (auth0_sub) DO UPDATE SET subscription_tier = EXCLUDED.subscription_tier `, ['test-user-123', 'test@example.com', 'free']); }); it('should enforce free tier limit (2 vehicles)', async () => { // Create 2 vehicles (at limit) await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate) VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123'), ($1, 2021, 'Toyota', 'Camry', 'XYZ789') `, ['test-user-123']); // Attempt to create a 3rd vehicle const response = await request(app) .post('/api/vehicles') .send({ year: 2022, make: 'Ford', model: 'F-150', licensePlate: 'TEST123' }) .expect(403); expect(response.body).toMatchObject({ error: 'TIER_REQUIRED', currentTier: 'free', requiredTier: 'pro', feature: 'vehicle.addBeyondLimit', context: { limit: 2, count: 2 } }); expect(response.body.upgradePrompt).toContain('Free tier is limited to 2 vehicles'); }); it('should allow free tier user to add vehicle when below limit', async () => { // Create 1 vehicle (below limit) await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate) VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123') `, ['test-user-123']); // Should be able to add 2nd vehicle const response = await request(app) .post('/api/vehicles') .send({ year: 2021, make: 'Toyota', model: 'Camry', licensePlate: 'XYZ789' }) .expect(201); expect(response.body).toMatchObject({ year: 2021, make: 'Toyota', model: 'Camry' }); }); it('should enforce pro tier limit (5 vehicles)', async () => { // Update user to pro tier await pool.query(` UPDATE user_profiles SET subscription_tier = 'pro' WHERE auth0_sub = $1 `, ['test-user-123']); // Create 5 vehicles (at limit) for (let i = 1; i <= 5; i++) { await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate) VALUES ($1, 2020, 'Honda', 'Civic', $2) `, ['test-user-123', `PLATE${i}`]); } // Attempt to create a 6th vehicle const response = await request(app) .post('/api/vehicles') .send({ year: 2022, make: 'Ford', model: 'F-150', licensePlate: 'TEST123' }) .expect(403); expect(response.body).toMatchObject({ error: 'TIER_REQUIRED', currentTier: 'pro', requiredTier: 'enterprise', context: { limit: 5, count: 5 } }); }); it('should allow enterprise tier unlimited vehicles', async () => { // Update user to enterprise tier await pool.query(` UPDATE user_profiles SET subscription_tier = 'enterprise' WHERE auth0_sub = $1 `, ['test-user-123']); // Create 10 vehicles (well beyond free/pro limits) for (let i = 1; i <= 10; i++) { await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate) VALUES ($1, 2020, 'Honda', 'Civic', $2) `, ['test-user-123', `PLATE${i}`]); } // Should still be able to add another vehicle const response = await request(app) .post('/api/vehicles') .send({ year: 2022, make: 'Ford', model: 'F-150', licensePlate: 'ENTERPRISE1' }) .expect(201); expect(response.body).toMatchObject({ year: 2022, make: 'Ford', model: 'F-150' }); }); it('should only count active vehicles towards limit', async () => { // Create 1 active vehicle and 1 deleted vehicle await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate) VALUES ($1, 2020, 'Honda', 'Civic', 'ABC123') `, ['test-user-123']); await pool.query(` INSERT INTO vehicles (user_id, year, make, model, license_plate, is_active, deleted_at) VALUES ($1, 2019, 'Toyota', 'Corolla', 'OLD123', false, NOW()) `, ['test-user-123']); // Should be able to add 2nd active vehicle (deleted one doesn't count) const response = await request(app) .post('/api/vehicles') .send({ year: 2021, make: 'Toyota', model: 'Camry', licensePlate: 'XYZ789' }) .expect(201); expect(response.body).toMatchObject({ year: 2021, make: 'Toyota' }); }); }); });