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

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:
Eric Gullickson
2026-01-11 16:36:53 -06:00
parent dff743ca36
commit 20189a1d37
15 changed files with 1179 additions and 48 deletions

View File

@@ -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']
);
});
});
});

View File

@@ -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', () => {