All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m37s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Backend: - Add VEHICLE_LIMITS configuration to feature-tiers.ts - Add getVehicleLimit, canAddVehicle helper functions - Implement transaction-based limit check with FOR UPDATE locking - Add VehicleLimitExceededError and 403 TIER_REQUIRED response - Add countByUserId to VehiclesRepository - Add comprehensive tests for all limit logic Frontend: - Add getResourceLimit, isAtResourceLimit to useTierAccess hook - Create VehicleLimitDialog component with mobile/desktop modes - Add useVehicleLimitCheck shared hook for limit state - Update VehiclesPage with limit checks and lock icon - Update VehiclesMobileScreen with limit checks - Add tests for VehicleLimitDialog Implements vehicle limits per tier (Free: 2, Pro: 5, Enterprise: unlimited) with race condition prevention and consistent UX across mobile/desktop. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
/**
|
|
* @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'
|
|
});
|
|
});
|
|
});
|
|
}); |