feat: Implement user tier-based feature gating system (refs #8)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

Add subscription tier system to gate features behind Free/Pro/Enterprise tiers.

Backend:
- Create feature-tiers.ts with FEATURE_TIERS config and utilities
- Add /api/config/feature-tiers endpoint for frontend config fetch
- Create requireTier middleware for route-level tier enforcement
- Add subscriptionTier to request.userContext in auth plugin
- Gate scanForMaintenance in documents controller (Pro+ required)
- Add migration to reset scanForMaintenance for free users

Frontend:
- Create useTierAccess hook for tier checking
- Create UpgradeRequiredDialog component (responsive)
- Gate DocumentForm checkbox with lock icon for free users
- Add SubscriptionTier type to profile.types.ts

Documentation:
- Add TIER-GATING.md with usage guide

Tests: 30 passing (feature-tiers, tier-guard, controller)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-04 14:34:47 -06:00
parent 453083b7db
commit f494f77150
18 changed files with 1544 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ import fastifyMultipart from '@fastify/multipart';
// Core plugins
import authPlugin from './core/plugins/auth.plugin';
import adminGuardPlugin, { setAdminGuardPool } from './core/plugins/admin-guard.plugin';
import tierGuardPlugin from './core/plugins/tier-guard.plugin';
import loggingPlugin from './core/plugins/logging.plugin';
import errorPlugin from './core/plugins/error.plugin';
import { appConfig } from './core/config/config-loader';
@@ -30,6 +31,7 @@ import { onboardingRoutes } from './features/onboarding';
import { userPreferencesRoutes } from './features/user-preferences';
import { userExportRoutes } from './features/user-export';
import { pool } from './core/config/database';
import { configRoutes } from './core/config/config.routes';
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
@@ -80,13 +82,16 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(adminGuardPlugin);
setAdminGuardPool(pool);
// Tier guard plugin - for subscription tier enforcement
await app.register(tierGuardPlugin);
// Health check
app.get('/health', async (_request, reply) => {
return reply.code(200).send({
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
});
});
@@ -96,7 +101,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'auth', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
});
});
@@ -136,6 +141,7 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(userProfileRoutes, { prefix: '/api' });
await app.register(userPreferencesRoutes, { prefix: '/api' });
await app.register(userExportRoutes, { prefix: '/api' });
await app.register(configRoutes, { prefix: '/api' });
// 404 handler
app.setNotFoundHandler(async (_request, reply) => {

View File

@@ -0,0 +1,18 @@
/**
* @ai-summary Configuration API routes
* @ai-context Exposes feature tier configuration for frontend consumption
*/
import { FastifyPluginAsync } from 'fastify';
import { getAllFeatureConfigs, TIER_LEVELS } from './feature-tiers';
export const configRoutes: FastifyPluginAsync = async (fastify) => {
// GET /api/config/feature-tiers - Get all feature tier configurations
// Public endpoint - no auth required (config is not sensitive)
fastify.get('/config/feature-tiers', async (_request, reply) => {
return reply.code(200).send({
tiers: TIER_LEVELS,
features: getAllFeatureConfigs(),
});
});
};

View File

@@ -0,0 +1,73 @@
/**
* @ai-summary Feature tier configuration and utilities
* @ai-context Defines feature-to-tier mapping for gating premium features
*/
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
// Tier hierarchy: higher number = higher access level
export const TIER_LEVELS: Record<SubscriptionTier, number> = {
free: 0,
pro: 1,
enterprise: 2,
} as const;
// Feature configuration interface
export interface FeatureConfig {
minTier: SubscriptionTier;
name: string;
upgradePrompt: string;
}
// Feature registry - add new gated features here
export const FEATURE_TIERS: Record<string, FeatureConfig> = {
'document.scanMaintenanceSchedule': {
minTier: 'pro',
name: 'Scan for Maintenance Schedule',
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
},
} as const;
/**
* Get numeric level for a subscription tier
*/
export function getTierLevel(tier: SubscriptionTier): number {
return TIER_LEVELS[tier] ?? 0;
}
/**
* Check if a user tier can access a feature
* Higher tiers inherit access to all lower tier features
*/
export function canAccessFeature(userTier: SubscriptionTier, featureKey: string): boolean {
const feature = FEATURE_TIERS[featureKey];
if (!feature) {
// Unknown features are accessible by all (fail open for unlisted features)
return true;
}
return getTierLevel(userTier) >= getTierLevel(feature.minTier);
}
/**
* Get the minimum required tier for a feature
* Returns null if feature is not gated
*/
export function getRequiredTier(featureKey: string): SubscriptionTier | null {
const feature = FEATURE_TIERS[featureKey];
return feature?.minTier ?? null;
}
/**
* Get full feature configuration
* Returns undefined if feature is not registered
*/
export function getFeatureConfig(featureKey: string): FeatureConfig | undefined {
return FEATURE_TIERS[featureKey];
}
/**
* Get all feature configurations (for API endpoint)
*/
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
return { ...FEATURE_TIERS };
}

View File

@@ -0,0 +1,104 @@
import {
TIER_LEVELS,
FEATURE_TIERS,
getTierLevel,
canAccessFeature,
getRequiredTier,
getFeatureConfig,
getAllFeatureConfigs,
} from '../feature-tiers';
describe('feature-tiers', () => {
describe('TIER_LEVELS', () => {
it('defines correct tier hierarchy', () => {
expect(TIER_LEVELS.free).toBe(0);
expect(TIER_LEVELS.pro).toBe(1);
expect(TIER_LEVELS.enterprise).toBe(2);
});
it('enterprise > pro > free', () => {
expect(TIER_LEVELS.enterprise).toBeGreaterThan(TIER_LEVELS.pro);
expect(TIER_LEVELS.pro).toBeGreaterThan(TIER_LEVELS.free);
});
});
describe('FEATURE_TIERS', () => {
it('includes scanMaintenanceSchedule feature', () => {
const feature = FEATURE_TIERS['document.scanMaintenanceSchedule'];
expect(feature).toBeDefined();
expect(feature.minTier).toBe('pro');
expect(feature.name).toBe('Scan for Maintenance Schedule');
expect(feature.upgradePrompt).toBeTruthy();
});
});
describe('getTierLevel', () => {
it('returns correct level for each tier', () => {
expect(getTierLevel('free')).toBe(0);
expect(getTierLevel('pro')).toBe(1);
expect(getTierLevel('enterprise')).toBe(2);
});
it('returns 0 for unknown tier', () => {
expect(getTierLevel('unknown' as any)).toBe(0);
});
});
describe('canAccessFeature', () => {
const featureKey = 'document.scanMaintenanceSchedule';
it('denies access for free tier to pro feature', () => {
expect(canAccessFeature('free', featureKey)).toBe(false);
});
it('allows access for pro tier to pro feature', () => {
expect(canAccessFeature('pro', featureKey)).toBe(true);
});
it('allows access for enterprise tier to pro feature (inheritance)', () => {
expect(canAccessFeature('enterprise', featureKey)).toBe(true);
});
it('allows access for unknown feature (fail open)', () => {
expect(canAccessFeature('free', 'unknown.feature')).toBe(true);
expect(canAccessFeature('pro', 'unknown.feature')).toBe(true);
expect(canAccessFeature('enterprise', 'unknown.feature')).toBe(true);
});
});
describe('getRequiredTier', () => {
it('returns required tier for known feature', () => {
expect(getRequiredTier('document.scanMaintenanceSchedule')).toBe('pro');
});
it('returns null for unknown feature', () => {
expect(getRequiredTier('unknown.feature')).toBeNull();
});
});
describe('getFeatureConfig', () => {
it('returns full config for known feature', () => {
const config = getFeatureConfig('document.scanMaintenanceSchedule');
expect(config).toEqual({
minTier: 'pro',
name: 'Scan for Maintenance Schedule',
upgradePrompt: expect.any(String),
});
});
it('returns undefined for unknown feature', () => {
expect(getFeatureConfig('unknown.feature')).toBeUndefined();
});
});
describe('getAllFeatureConfigs', () => {
it('returns copy of all feature configs', () => {
const configs = getAllFeatureConfigs();
expect(configs['document.scanMaintenanceSchedule']).toBeDefined();
// Verify it's a copy, not the original
configs['test'] = { minTier: 'free', name: 'test', upgradePrompt: '' };
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
});
});
});

View File

@@ -12,6 +12,7 @@ import { logger } from '../logging/logger';
import { UserProfileRepository } from '../../features/user-profile/data/user-profile.repository';
import { pool } from '../config/database';
import { auth0ManagementClient } from '../auth/auth0-management.client';
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
// Routes that don't require email verification
const VERIFICATION_EXEMPT_ROUTES = [
@@ -56,6 +57,7 @@ declare module 'fastify' {
onboardingCompleted: boolean;
isAdmin: boolean;
adminRecord?: any;
subscriptionTier: SubscriptionTier;
};
}
}
@@ -129,6 +131,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
let displayName: string | undefined;
let emailVerified = false;
let onboardingCompleted = false;
let subscriptionTier: SubscriptionTier = 'free';
try {
// If JWT doesn't have email, fetch from Auth0 Management API
@@ -170,6 +173,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
displayName = profile.displayName || undefined;
emailVerified = profile.emailVerified;
onboardingCompleted = profile.onboardingCompletedAt !== null;
subscriptionTier = profile.subscriptionTier || 'free';
// Sync email verification status from Auth0 if needed
if (!emailVerified) {
@@ -208,6 +212,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
emailVerified,
onboardingCompleted,
isAdmin: false, // Default to false; admin status checked by admin guard
subscriptionTier,
};
// Email verification guard - block unverified users from non-exempt routes

View File

@@ -0,0 +1,205 @@
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import tierGuardPlugin from '../tier-guard.plugin';
const createReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => {
return {
sent: false,
code: jest.fn(function(this: any, status: number) {
this.statusCode = status;
return this;
}),
send: jest.fn(function(this: any, payload: unknown) {
this.payload = payload;
this.sent = true;
return this;
}),
};
};
describe('tier guard plugin', () => {
let fastify: FastifyInstance;
let authenticateMock: jest.Mock;
beforeEach(async () => {
fastify = Fastify();
// Mock authenticate to set userContext
authenticateMock = jest.fn(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'free',
};
});
fastify.decorate('authenticate', authenticateMock);
await fastify.register(tierGuardPlugin);
});
afterEach(async () => {
await fastify.close();
jest.clearAllMocks();
});
describe('requireTier with minTier', () => {
it('allows access when user tier meets minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'pro',
};
});
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ minTier: 'pro' });
await handler(request, reply as FastifyReply);
expect(authenticateMock).toHaveBeenCalledTimes(1);
expect(reply.code).not.toHaveBeenCalled();
expect(reply.send).not.toHaveBeenCalled();
});
it('allows access when user tier exceeds minimum', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'enterprise',
};
});
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ minTier: 'pro' });
await handler(request, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
});
it('denies access when user tier is below minimum', async () => {
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ minTier: 'pro' });
await handler(request, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
})
);
});
});
describe('requireTier with featureKey', () => {
it('denies free tier access to pro feature', async () => {
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
await handler(request, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
feature: 'document.scanMaintenanceSchedule',
featureName: 'Scan for Maintenance Schedule',
})
);
});
it('allows pro tier access to pro feature', async () => {
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
request.userContext = {
userId: 'auth0|user123',
email: 'user@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'pro',
};
});
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' });
await handler(request, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
});
it('allows access for unknown feature (fail open)', async () => {
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ featureKey: 'unknown.feature' });
await handler(request, reply as FastifyReply);
expect(reply.code).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
it('returns 500 when authenticate handler is not a function', async () => {
const brokenFastify = Fastify();
// Decorate with a non-function value to simulate missing handler
brokenFastify.decorate('authenticate', 'not-a-function' as any);
await brokenFastify.register(tierGuardPlugin);
const request = {} as FastifyRequest;
const reply = createReply();
const handler = brokenFastify.requireTier({ minTier: 'pro' });
await handler(request, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(500);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Internal server error',
message: 'Authentication handler missing',
})
);
await brokenFastify.close();
});
it('defaults to free tier when userContext is missing', async () => {
authenticateMock.mockImplementation(async () => {
// Don't set userContext
});
const request = {} as FastifyRequest;
const reply = createReply();
const handler = fastify.requireTier({ minTier: 'pro' });
await handler(request, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
currentTier: 'free',
})
);
});
});
});

View File

@@ -0,0 +1,126 @@
/**
* @ai-summary Fastify tier authorization plugin
* @ai-context Enforces subscription tier requirements for protected routes
*/
import { FastifyPluginAsync, FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { logger } from '../logging/logger';
import { SubscriptionTier } from '../../features/user-profile/domain/user-profile.types';
import { canAccessFeature, getFeatureConfig, getTierLevel } from '../config/feature-tiers';
// Tier check options
export interface TierCheckOptions {
minTier?: SubscriptionTier;
featureKey?: string;
}
declare module 'fastify' {
interface FastifyInstance {
requireTier: (options: TierCheckOptions) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}
const tierGuardPlugin: FastifyPluginAsync = async (fastify) => {
/**
* Creates a preHandler that enforces tier requirements
*
* Usage:
* fastify.get('/premium-route', {
* preHandler: [fastify.requireTier({ minTier: 'pro' })],
* handler: controller.method
* });
*
* Or with feature key:
* fastify.post('/documents', {
* preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
* handler: controller.method
* });
*/
fastify.decorate('requireTier', function(this: FastifyInstance, options: TierCheckOptions) {
const { minTier, featureKey } = options;
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
try {
// Ensure user is authenticated first
if (typeof this.authenticate !== 'function') {
logger.error('Tier guard: authenticate handler missing');
return reply.code(500).send({
error: 'Internal server error',
message: 'Authentication handler missing',
});
}
await this.authenticate(request, reply);
if (reply.sent) {
return;
}
// Get user's subscription tier from context
const userTier = request.userContext?.subscriptionTier || 'free';
// Determine required tier and check access
let hasAccess = false;
let requiredTier: SubscriptionTier = 'free';
let upgradePrompt: string | undefined;
let featureName: string | undefined;
if (featureKey) {
// Feature-based tier check
hasAccess = canAccessFeature(userTier, featureKey);
const config = getFeatureConfig(featureKey);
requiredTier = config?.minTier || 'pro';
upgradePrompt = config?.upgradePrompt;
featureName = config?.name;
} else if (minTier) {
// Direct tier comparison
hasAccess = getTierLevel(userTier) >= getTierLevel(minTier);
requiredTier = minTier;
} else {
// No tier requirement specified - allow access
hasAccess = true;
}
if (!hasAccess) {
logger.warn('Tier guard: user tier insufficient', {
userId: request.userContext?.userId?.substring(0, 8) + '...',
userTier,
requiredTier,
featureKey,
});
return reply.code(403).send({
error: 'TIER_REQUIRED',
requiredTier,
currentTier: userTier,
feature: featureKey || null,
featureName: featureName || null,
upgradePrompt: upgradePrompt || `Upgrade to ${requiredTier} to access this feature.`,
});
}
logger.debug('Tier guard: access granted', {
userId: request.userContext?.userId?.substring(0, 8) + '...',
userTier,
featureKey,
});
} catch (error) {
logger.error('Tier guard: authorization check failed', {
error: error instanceof Error ? error.message : 'Unknown error',
userId: request.userContext?.userId?.substring(0, 8) + '...',
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Tier check failed',
});
}
};
});
};
export default fp(tierGuardPlugin, {
name: 'tier-guard-plugin',
// Note: Requires auth-plugin to be registered first for authenticate decorator
// Dependency check removed to allow testing with mock authenticate
});

View File

@@ -28,7 +28,10 @@ describe('admin guard plugin', () => {
request.userContext = {
userId: 'auth0|admin',
email: 'admin@motovaultpro.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'free',
};
});
fastify.decorate('authenticate', authenticateMock);

View File

@@ -8,6 +8,8 @@ import { Transform, TransformCallback } from 'stream';
import crypto from 'crypto';
import FileType from 'file-type';
import { Readable } from 'stream';
import { canAccessFeature, getFeatureConfig } from '../../../core/config/feature-tiers';
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
export class DocumentsController {
private readonly service = new DocumentsService();
@@ -73,6 +75,7 @@ export class DocumentsController {
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
logger.info('Document create requested', {
operation: 'documents.create',
@@ -82,6 +85,26 @@ export class DocumentsController {
title: request.body.title,
});
// Tier validation: scanForMaintenance requires Pro tier
const featureKey = 'document.scanMaintenanceSchedule';
if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) {
const config = getFeatureConfig(featureKey);
logger.warn('Tier required for scanForMaintenance', {
operation: 'documents.create.tier_required',
userId,
userTier,
requiredTier: config?.minTier,
});
return reply.code(403).send({
error: 'TIER_REQUIRED',
requiredTier: config?.minTier || 'pro',
currentTier: userTier,
feature: featureKey,
featureName: config?.name || null,
upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.',
});
}
const created = await this.service.createDocument(userId, request.body);
logger.info('Document created', {
@@ -98,6 +121,7 @@ export class DocumentsController {
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
const userId = (request as any).user?.sub as string;
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
const documentId = request.params.id;
logger.info('Document update requested', {
@@ -107,6 +131,27 @@ export class DocumentsController {
updateFields: Object.keys(request.body),
});
// Tier validation: scanForMaintenance requires Pro tier
const featureKey = 'document.scanMaintenanceSchedule';
if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) {
const config = getFeatureConfig(featureKey);
logger.warn('Tier required for scanForMaintenance', {
operation: 'documents.update.tier_required',
userId,
documentId,
userTier,
requiredTier: config?.minTier,
});
return reply.code(403).send({
error: 'TIER_REQUIRED',
requiredTier: config?.minTier || 'pro',
currentTier: userTier,
feature: featureKey,
featureName: config?.name || null,
upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.',
});
}
const updated = await this.service.updateDocument(userId, documentId, request.body);
if (!updated) {
logger.warn('Document not found for update', {

View File

@@ -0,0 +1,10 @@
-- Migration: Reset scanForMaintenance for free tier users
-- This migration is part of the tier-gating feature implementation.
-- scanForMaintenance is now a Pro feature, so existing free users with it enabled need to be reset.
UPDATE documents d
SET scan_for_maintenance = false
FROM user_profiles u
WHERE d.user_id = u.auth0_sub
AND u.subscription_tier = 'free'
AND d.scan_for_maintenance = true;

View File

@@ -0,0 +1,299 @@
/**
* @ai-summary Unit tests for tier validation in DocumentsController
* @ai-context Tests that free users cannot use scanForMaintenance feature
*/
// Mock config and dependencies first (before any imports that might use them)
jest.mock('../../../../core/config/config-loader', () => ({
appConfig: {
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
getRedisUrl: () => 'redis://localhost',
get: () => ({}),
},
config: {
database: { connectionString: 'mock' },
redis: { url: 'mock' },
auth0: { domain: 'mock', clientId: 'mock', audience: 'mock' },
storage: { provider: 'filesystem', root: '/tmp' },
logging: { level: 'error' },
},
}));
jest.mock('../../../../core/config/database', () => ({
pool: {
query: jest.fn(),
connect: jest.fn(),
end: jest.fn(),
},
default: {
query: jest.fn(),
connect: jest.fn(),
end: jest.fn(),
},
}));
jest.mock('../../../../core/logging/logger', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('../../../../core/storage/storage.service', () => ({
getStorageService: jest.fn(() => ({
putObject: jest.fn(),
getObjectStream: jest.fn(),
deleteObject: jest.fn(),
headObject: jest.fn(),
})),
}));
jest.mock('../../domain/documents.service');
import { FastifyRequest, FastifyReply } from 'fastify';
import { DocumentsController } from '../../api/documents.controller';
import { DocumentsService } from '../../domain/documents.service';
const MockedService = jest.mocked(DocumentsService);
describe('DocumentsController - Tier Validation', () => {
let controller: DocumentsController;
let mockServiceInstance: jest.Mocked<DocumentsService>;
const createMockRequest = (overrides: Partial<FastifyRequest> = {}): FastifyRequest => ({
user: { sub: 'user-123' },
userContext: {
userId: 'user-123',
email: 'test@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'free',
},
body: {},
params: {},
query: {},
...overrides,
} as unknown as FastifyRequest);
const createMockReply = (): Partial<FastifyReply> & { payload?: unknown; statusCode?: number } => ({
sent: false,
code: jest.fn(function(this: any, status: number) {
this.statusCode = status;
return this;
}),
send: jest.fn(function(this: any, payload: unknown) {
this.payload = payload;
this.sent = true;
return this;
}),
});
beforeEach(() => {
jest.clearAllMocks();
mockServiceInstance = {
createDocument: jest.fn(),
updateDocument: jest.fn(),
getDocument: jest.fn(),
listDocuments: jest.fn(),
deleteDocument: jest.fn(),
} as any;
MockedService.mockImplementation(() => mockServiceInstance);
controller = new DocumentsController();
});
describe('create - scanForMaintenance tier gating', () => {
const baseDocumentBody = {
vehicleId: 'vehicle-123',
documentType: 'manual',
title: 'Service Manual',
};
it('allows free user to create document without scanForMaintenance', async () => {
const request = createMockRequest({
body: { ...baseDocumentBody, scanForMaintenance: false },
});
const reply = createMockReply();
mockServiceInstance.createDocument.mockResolvedValue({
id: 'doc-123',
userId: 'user-123',
vehicleId: 'vehicle-123',
documentType: 'manual',
title: 'Service Manual',
scanForMaintenance: false,
} as any);
await controller.create(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(201);
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
});
it('blocks free user from using scanForMaintenance=true', async () => {
const request = createMockRequest({
body: { ...baseDocumentBody, scanForMaintenance: true },
});
const reply = createMockReply();
await controller.create(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
feature: 'document.scanMaintenanceSchedule',
featureName: 'Scan for Maintenance Schedule',
})
);
expect(mockServiceInstance.createDocument).not.toHaveBeenCalled();
});
it('allows pro user to use scanForMaintenance=true', async () => {
const request = createMockRequest({
body: { ...baseDocumentBody, scanForMaintenance: true },
userContext: {
userId: 'user-123',
email: 'pro@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'pro',
},
});
const reply = createMockReply();
mockServiceInstance.createDocument.mockResolvedValue({
id: 'doc-123',
userId: 'user-123',
vehicleId: 'vehicle-123',
documentType: 'manual',
title: 'Service Manual',
scanForMaintenance: true,
} as any);
await controller.create(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(201);
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
});
it('allows enterprise user to use scanForMaintenance=true', async () => {
const request = createMockRequest({
body: { ...baseDocumentBody, scanForMaintenance: true },
userContext: {
userId: 'user-123',
email: 'enterprise@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'enterprise',
},
});
const reply = createMockReply();
mockServiceInstance.createDocument.mockResolvedValue({
id: 'doc-123',
userId: 'user-123',
vehicleId: 'vehicle-123',
documentType: 'manual',
title: 'Service Manual',
scanForMaintenance: true,
} as any);
await controller.create(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(201);
expect(mockServiceInstance.createDocument).toHaveBeenCalled();
});
it('defaults to free tier when userContext is missing', async () => {
const request = createMockRequest({
body: { ...baseDocumentBody, scanForMaintenance: true },
userContext: undefined,
});
const reply = createMockReply();
await controller.create(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
currentTier: 'free',
})
);
});
});
describe('update - scanForMaintenance tier gating', () => {
const documentId = 'doc-123';
it('allows free user to update document without scanForMaintenance', async () => {
const request = createMockRequest({
params: { id: documentId },
body: { title: 'Updated Title' },
});
const reply = createMockReply();
mockServiceInstance.updateDocument.mockResolvedValue({
id: documentId,
title: 'Updated Title',
} as any);
await controller.update(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(200);
expect(mockServiceInstance.updateDocument).toHaveBeenCalled();
});
it('blocks free user from setting scanForMaintenance=true on update', async () => {
const request = createMockRequest({
params: { id: documentId },
body: { scanForMaintenance: true },
});
const reply = createMockReply();
await controller.update(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(403);
expect(reply.send).toHaveBeenCalledWith(
expect.objectContaining({
error: 'TIER_REQUIRED',
requiredTier: 'pro',
currentTier: 'free',
feature: 'document.scanMaintenanceSchedule',
})
);
expect(mockServiceInstance.updateDocument).not.toHaveBeenCalled();
});
it('allows pro user to set scanForMaintenance=true on update', async () => {
const request = createMockRequest({
params: { id: documentId },
body: { scanForMaintenance: true },
userContext: {
userId: 'user-123',
email: 'pro@example.com',
emailVerified: true,
onboardingCompleted: true,
isAdmin: false,
subscriptionTier: 'pro',
},
});
const reply = createMockReply();
mockServiceInstance.updateDocument.mockResolvedValue({
id: documentId,
scanForMaintenance: true,
} as any);
await controller.update(request as any, reply as FastifyReply);
expect(reply.code).toHaveBeenCalledWith(200);
expect(mockServiceInstance.updateDocument).toHaveBeenCalled();
});
});
});