feat: Add tier-based vehicle limit enforcement (refs #23)
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
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>
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { app } from '../../../../app';
|
||||
import pool from '../../../../core/config/database';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import { readFileSync } from 'fs';
|
||||
@@ -13,15 +12,19 @@ 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: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
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 () => {
|
||||
@@ -263,7 +266,7 @@ describe('Vehicles Integration Tests', () => {
|
||||
|
||||
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);
|
||||
@@ -271,4 +274,183 @@ describe('Vehicles Integration Tests', () => {
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for VehiclesRepository
|
||||
* @ai-context Tests repository data access methods with mocked database
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { VehiclesRepository } from '../../data/vehicles.repository';
|
||||
|
||||
describe('VehiclesRepository', () => {
|
||||
let pool: Pool;
|
||||
let repository: VehiclesRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
pool = {
|
||||
query: jest.fn(),
|
||||
} as any;
|
||||
repository = new VehiclesRepository(pool);
|
||||
});
|
||||
|
||||
describe('countByUserId', () => {
|
||||
it('returns accurate count of active vehicles', async () => {
|
||||
const mockResult = {
|
||||
rows: [{ count: '3' }],
|
||||
};
|
||||
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const count = await repository.countByUserId('user-123');
|
||||
|
||||
expect(count).toBe(3);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT COUNT(*) as count FROM vehicles'),
|
||||
['user-123']
|
||||
);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('is_active = true'),
|
||||
['user-123']
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 0 for user with no vehicles', async () => {
|
||||
const mockResult = {
|
||||
rows: [{ count: '0' }],
|
||||
};
|
||||
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const count = await repository.countByUserId('user-empty');
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('only counts active vehicles (excludes deleted)', async () => {
|
||||
const mockResult = {
|
||||
rows: [{ count: '2' }],
|
||||
};
|
||||
(pool.query as jest.Mock).mockResolvedValue(mockResult);
|
||||
|
||||
const count = await repository.countByUserId('user-with-deleted');
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('is_active = true'),
|
||||
['user-with-deleted']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,10 +53,19 @@ describe('VehiclesService', () => {
|
||||
findByUserAndVIN: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
countByUserId: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockPool = {
|
||||
query: jest.fn(),
|
||||
connect: jest.fn().mockResolvedValue({
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
mockRepository.mockImplementation(() => repositoryInstance);
|
||||
service = new VehiclesService(repositoryInstance);
|
||||
service = new VehiclesService(repositoryInstance, mockPool);
|
||||
});
|
||||
|
||||
describe('dropdown data integration', () => {
|
||||
|
||||
Reference in New Issue
Block a user