feature flags
This commit is contained in:
@@ -132,6 +132,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/signup-sponsor": {
|
||||
"post": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/dashboard/overview": {
|
||||
"get": {
|
||||
"responses": {
|
||||
@@ -1449,6 +1458,9 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"companyId": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6013,6 +6025,29 @@
|
||||
"displayName"
|
||||
]
|
||||
},
|
||||
"SignupSponsorParamsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"companyName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password",
|
||||
"displayName",
|
||||
"companyName"
|
||||
]
|
||||
},
|
||||
"SponsorDashboardDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ProtestsModule } from './domain/protests/ProtestsModule';
|
||||
import { RaceModule } from './domain/race/RaceModule';
|
||||
import { SponsorModule } from './domain/sponsor/SponsorModule';
|
||||
import { TeamModule } from './domain/team/TeamModule';
|
||||
import { FeaturesModule } from './features/features.module';
|
||||
|
||||
import { getApiPersistence, getEnableBootstrap } from './env';
|
||||
import { RequestContextMiddleware } from './shared/http/RequestContext';
|
||||
@@ -46,6 +47,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
|
||||
PaymentsModule,
|
||||
PolicyModule,
|
||||
AdminModule,
|
||||
FeaturesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -27,8 +27,20 @@ describe('Feature Flag Configuration', () => {
|
||||
|
||||
expect(result.loadedFrom).toBe('config-file');
|
||||
expect(result.features).toBeDefined();
|
||||
|
||||
// Core platform features (alpha mode - all enabled)
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
expect(result.features['platform.leagues']).toBe('enabled');
|
||||
|
||||
// Sponsor features
|
||||
expect(result.features['sponsors.portal']).toBe('enabled');
|
||||
expect(result.features['sponsors.management']).toBe('enabled');
|
||||
|
||||
// Admin features
|
||||
expect(result.features['admin.dashboard']).toBe('enabled');
|
||||
|
||||
// Beta features (development has them enabled for testing)
|
||||
expect(result.features['beta.newUI']).toBe('enabled');
|
||||
});
|
||||
|
||||
it('should load test config when NODE_ENV=test', async () => {
|
||||
@@ -36,8 +48,18 @@ describe('Feature Flag Configuration', () => {
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
expect(result.loadedFrom).toBe('config-file');
|
||||
|
||||
// Core platform features
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
|
||||
// Sponsor features
|
||||
expect(result.features['sponsors.portal']).toBe('enabled');
|
||||
|
||||
// Admin features
|
||||
expect(result.features['admin.dashboard']).toBe('enabled');
|
||||
|
||||
// Beta features (disabled in test)
|
||||
expect(result.features['beta.newUI']).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should load production config when NODE_ENV=production', async () => {
|
||||
@@ -45,11 +67,22 @@ describe('Feature Flag Configuration', () => {
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
expect(result.loadedFrom).toBe('config-file');
|
||||
|
||||
// Core platform features (stable)
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
|
||||
// Sponsor features (gradual rollout - management disabled)
|
||||
expect(result.features['sponsors.portal']).toBe('enabled');
|
||||
expect(result.features['sponsors.management']).toBe('disabled');
|
||||
|
||||
// Admin features (analytics disabled)
|
||||
expect(result.features['admin.dashboard']).toBe('enabled');
|
||||
expect(result.features['admin.analytics']).toBe('disabled');
|
||||
|
||||
// Beta features (disabled in production)
|
||||
expect(result.features['beta.newUI']).toBe('disabled');
|
||||
});
|
||||
|
||||
|
||||
it('should handle invalid environment', async () => {
|
||||
process.env.NODE_ENV = 'invalid-env';
|
||||
|
||||
@@ -107,4 +140,24 @@ describe('Feature Flag Configuration', () => {
|
||||
expect(result.features['admin.dashboard']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature state differences', () => {
|
||||
it('should correctly handle all feature states', async () => {
|
||||
process.env.NODE_ENV = 'staging';
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
// 'enabled' - fully available
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
|
||||
// 'disabled' - turned off completely
|
||||
expect(result.features['beta.experimental']).toBe('hidden');
|
||||
|
||||
// 'coming_soon' - visible but not available
|
||||
expect(result.features['sponsors.management']).toBe('coming_soon');
|
||||
expect(result.features['beta.newUI']).toBe('coming_soon');
|
||||
|
||||
// 'hidden' - completely invisible
|
||||
expect(result.features['beta.experimental']).toBe('hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,12 @@
|
||||
* Feature Flag Configuration Types
|
||||
*
|
||||
* Provides type-safe configuration for feature flags across different environments
|
||||
*
|
||||
* FEATURE STATES:
|
||||
* - 'enabled': Feature is fully available to users
|
||||
* - 'disabled': Feature is turned off (not visible/functional)
|
||||
* - 'coming_soon': Feature is visible but not yet available (shows "coming soon")
|
||||
* - 'hidden': Feature is completely invisible (internal/experimental only)
|
||||
*/
|
||||
|
||||
export type FeatureState = 'enabled' | 'disabled' | 'coming_soon' | 'hidden';
|
||||
|
||||
@@ -2,33 +2,121 @@ import { FeatureFlagConfig } from './feature-types';
|
||||
|
||||
/**
|
||||
* Feature flag configuration for all environments
|
||||
* This provides type safety, IntelliSense, and environment-specific settings
|
||||
*
|
||||
* ARCHITECTURE: API-Driven Features
|
||||
* - All feature control comes from the API /features endpoint
|
||||
* - FeatureFlagService fetches and caches features
|
||||
* - Components use FeatureFlagService or ModeGuard for conditional rendering
|
||||
*
|
||||
* FEATURE STATES - DETAILED EXPLANATION:
|
||||
*
|
||||
* 'enabled' = Feature is fully available to users
|
||||
* - Visible in UI
|
||||
* - Fully functional
|
||||
* - No restrictions
|
||||
* - Example: "Users can create leagues"
|
||||
*
|
||||
* 'disabled' = Feature is turned off for everyone
|
||||
* - Not visible in UI (or shown as unavailable)
|
||||
* - Non-functional
|
||||
* - Users cannot access it
|
||||
* - Use when: Feature is broken, not ready, or intentionally removed
|
||||
* - Example: "Sponsor management disabled due to bug"
|
||||
*
|
||||
* 'coming_soon' = Feature is visible but not yet available
|
||||
* - Visible in UI with "Coming Soon" badge
|
||||
* - Shows users what's coming
|
||||
* - Builds anticipation
|
||||
* - Use when: Feature is in development, users should know about it
|
||||
* - Example: "New UI coming soon" - users see it but can't use it
|
||||
*
|
||||
* 'hidden' = Feature is completely invisible
|
||||
* - Not shown in UI at all
|
||||
* - No user knows it exists
|
||||
* - Use when: Feature is experimental, internal-only, or not ready for ANY visibility
|
||||
* - Example: "Experimental AI feature" - only devs know it exists
|
||||
*
|
||||
* DECISION TREE:
|
||||
* - Should users see this feature exists?
|
||||
* - NO → 'hidden' or 'disabled'
|
||||
* - YES → Should they be able to use it?
|
||||
* - NO → 'coming_soon'
|
||||
* - YES → 'enabled'
|
||||
*
|
||||
* REAL-WORLD EXAMPLES:
|
||||
* - 'enabled': Dashboard, leagues, teams (core features working)
|
||||
* - 'disabled': Sponsor management (broken, don't show anything)
|
||||
* - 'coming_soon': New UI redesign (users see "coming soon" banner)
|
||||
* - 'hidden': Experimental chat feature (internal testing only)
|
||||
*/
|
||||
export const featureConfig: FeatureFlagConfig = {
|
||||
// Development environment - features for local development
|
||||
// Development environment - all features enabled for testing
|
||||
development: {
|
||||
// Core platform features
|
||||
platform: {
|
||||
dashboard: 'enabled',
|
||||
leagues: 'enabled',
|
||||
teams: 'enabled',
|
||||
drivers: 'enabled',
|
||||
races: 'enabled',
|
||||
leaderboards: 'enabled',
|
||||
},
|
||||
// Authentication & onboarding
|
||||
auth: {
|
||||
signup: 'enabled',
|
||||
login: 'enabled',
|
||||
forgotPassword: 'enabled',
|
||||
resetPassword: 'enabled',
|
||||
},
|
||||
onboarding: {
|
||||
wizard: 'enabled',
|
||||
},
|
||||
// Sponsor features
|
||||
sponsors: {
|
||||
portal: 'enabled',
|
||||
dashboard: 'enabled',
|
||||
management: 'enabled',
|
||||
campaigns: 'enabled',
|
||||
billing: 'enabled',
|
||||
},
|
||||
// Admin features
|
||||
admin: {
|
||||
dashboard: 'enabled',
|
||||
userManagement: 'enabled',
|
||||
analytics: 'enabled',
|
||||
},
|
||||
// Beta features for testing
|
||||
beta: {
|
||||
newUI: 'enabled', // Enable new UI for testing
|
||||
newUI: 'enabled',
|
||||
experimental: 'coming_soon',
|
||||
},
|
||||
},
|
||||
|
||||
// Test environment - features for automated tests
|
||||
// Test environment - all features enabled for automated tests
|
||||
test: {
|
||||
platform: {
|
||||
dashboard: 'enabled',
|
||||
leagues: 'enabled',
|
||||
teams: 'enabled',
|
||||
drivers: 'enabled',
|
||||
races: 'enabled',
|
||||
leaderboards: 'enabled',
|
||||
},
|
||||
auth: {
|
||||
signup: 'enabled',
|
||||
login: 'enabled',
|
||||
forgotPassword: 'enabled',
|
||||
resetPassword: 'enabled',
|
||||
},
|
||||
onboarding: {
|
||||
wizard: 'enabled',
|
||||
},
|
||||
sponsors: {
|
||||
portal: 'enabled',
|
||||
dashboard: 'enabled',
|
||||
management: 'enabled',
|
||||
campaigns: 'enabled',
|
||||
billing: 'enabled',
|
||||
},
|
||||
admin: {
|
||||
dashboard: 'enabled',
|
||||
@@ -41,18 +129,42 @@ export const featureConfig: FeatureFlagConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Staging environment - features for pre-production testing
|
||||
// Staging environment - controlled feature rollout
|
||||
staging: {
|
||||
// Core platform features
|
||||
platform: {
|
||||
dashboard: 'enabled',
|
||||
leagues: 'enabled',
|
||||
teams: 'enabled',
|
||||
drivers: 'enabled',
|
||||
races: 'enabled',
|
||||
leaderboards: 'enabled',
|
||||
},
|
||||
// Authentication & onboarding
|
||||
auth: {
|
||||
signup: 'enabled',
|
||||
login: 'enabled',
|
||||
forgotPassword: 'enabled',
|
||||
resetPassword: 'enabled',
|
||||
},
|
||||
onboarding: {
|
||||
wizard: 'enabled',
|
||||
},
|
||||
// Sponsor features (gradual rollout)
|
||||
sponsors: {
|
||||
portal: 'enabled',
|
||||
dashboard: 'enabled',
|
||||
management: 'enabled',
|
||||
management: 'coming_soon', // Ready for testing but not fully rolled out
|
||||
campaigns: 'enabled',
|
||||
billing: 'enabled',
|
||||
},
|
||||
// Admin features
|
||||
admin: {
|
||||
dashboard: 'enabled',
|
||||
userManagement: 'enabled',
|
||||
analytics: 'enabled',
|
||||
},
|
||||
// Beta features (controlled rollout)
|
||||
beta: {
|
||||
newUI: 'coming_soon', // Ready for testing but not fully rolled out
|
||||
experimental: 'hidden',
|
||||
@@ -61,18 +173,42 @@ export const featureConfig: FeatureFlagConfig = {
|
||||
|
||||
// Production environment - stable features only
|
||||
production: {
|
||||
// Core platform features (stable)
|
||||
platform: {
|
||||
dashboard: 'enabled',
|
||||
leagues: 'enabled',
|
||||
teams: 'enabled',
|
||||
drivers: 'enabled',
|
||||
races: 'enabled',
|
||||
leaderboards: 'enabled',
|
||||
},
|
||||
// Authentication & onboarding (stable)
|
||||
auth: {
|
||||
signup: 'enabled',
|
||||
login: 'enabled',
|
||||
forgotPassword: 'enabled',
|
||||
resetPassword: 'enabled',
|
||||
},
|
||||
onboarding: {
|
||||
wizard: 'enabled',
|
||||
},
|
||||
// Sponsor features (gradual rollout)
|
||||
sponsors: {
|
||||
portal: 'enabled',
|
||||
dashboard: 'enabled',
|
||||
management: 'disabled', // Feature not ready yet
|
||||
campaigns: 'enabled',
|
||||
billing: 'enabled',
|
||||
},
|
||||
// Admin features (stable)
|
||||
admin: {
|
||||
dashboard: 'enabled',
|
||||
userManagement: 'enabled',
|
||||
analytics: 'disabled', // Feature not ready yet
|
||||
},
|
||||
// Beta features (controlled rollout)
|
||||
beta: {
|
||||
newUI: 'disabled',
|
||||
newUI: 'disabled', // Not ready for production
|
||||
experimental: 'hidden',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,28 +16,87 @@ describe('Feature Flag Integration Test', () => {
|
||||
expect(result.loadedFrom).toBe('config-file');
|
||||
expect(result.configPath).toBe('apps/api/src/config/features.config.ts');
|
||||
|
||||
// Verify specific features from our config
|
||||
// Verify core platform features (new structure)
|
||||
expect(result.features['platform.dashboard']).toBe('enabled');
|
||||
expect(result.features['platform.leagues']).toBe('enabled');
|
||||
expect(result.features['platform.teams']).toBe('enabled');
|
||||
|
||||
// Verify auth features
|
||||
expect(result.features['auth.signup']).toBe('enabled');
|
||||
expect(result.features['auth.login']).toBe('enabled');
|
||||
|
||||
// Verify sponsor features (expanded)
|
||||
expect(result.features['sponsors.portal']).toBe('enabled');
|
||||
expect(result.features['sponsors.dashboard']).toBe('enabled');
|
||||
expect(result.features['sponsors.campaigns']).toBe('enabled');
|
||||
|
||||
// Verify admin features
|
||||
expect(result.features['admin.dashboard']).toBe('enabled');
|
||||
expect(result.features['admin.userManagement']).toBe('enabled');
|
||||
|
||||
// Verify beta features (controlled in test)
|
||||
expect(result.features['beta.newUI']).toBe('disabled');
|
||||
expect(result.features['beta.experimental']).toBe('disabled');
|
||||
|
||||
// Verify utility functions work
|
||||
expect(isFeatureEnabled(result.features, 'sponsors.portal')).toBe(true);
|
||||
expect(isFeatureEnabled(result.features, 'platform.dashboard')).toBe(true);
|
||||
expect(isFeatureEnabled(result.features, 'beta.newUI')).toBe(false);
|
||||
expect(getFeatureState(result.features, 'sponsors.portal')).toBe('enabled');
|
||||
expect(getFeatureState(result.features, 'platform.dashboard')).toBe('enabled');
|
||||
expect(getFeatureState(result.features, 'nonexistent')).toBe('disabled');
|
||||
});
|
||||
|
||||
it('should work with different environments', async () => {
|
||||
// Test development environment
|
||||
// Test development environment - alpha mode (all enabled)
|
||||
process.env.NODE_ENV = 'development';
|
||||
const devResult = await loadFeatureConfig();
|
||||
expect(devResult.features['beta.newUI']).toBe('enabled'); // dev has beta enabled
|
||||
expect(devResult.features['beta.newUI']).toBe('enabled');
|
||||
expect(devResult.features['platform.dashboard']).toBe('enabled');
|
||||
expect(devResult.features['sponsors.management']).toBe('enabled');
|
||||
|
||||
// Test production environment
|
||||
// Test production environment - beta mode (controlled rollout)
|
||||
process.env.NODE_ENV = 'production';
|
||||
const prodResult = await loadFeatureConfig();
|
||||
expect(prodResult.features['beta.newUI']).toBe('disabled'); // prod has beta disabled
|
||||
expect(prodResult.features['beta.newUI']).toBe('disabled');
|
||||
expect(prodResult.features['platform.dashboard']).toBe('enabled');
|
||||
expect(prodResult.features['sponsors.management']).toBe('disabled'); // Not ready yet
|
||||
expect(prodResult.features['admin.analytics']).toBe('disabled'); // Not ready yet
|
||||
});
|
||||
|
||||
it('should handle the new two-tier architecture correctly', async () => {
|
||||
// Development should have all platform features enabled (alpha mode)
|
||||
process.env.NODE_ENV = 'development';
|
||||
const devResult = await loadFeatureConfig();
|
||||
|
||||
// All core platform features should be enabled
|
||||
expect(devResult.features['platform.dashboard']).toBe('enabled');
|
||||
expect(devResult.features['platform.leagues']).toBe('enabled');
|
||||
expect(devResult.features['platform.teams']).toBe('enabled');
|
||||
expect(devResult.features['platform.drivers']).toBe('enabled');
|
||||
expect(devResult.features['platform.races']).toBe('enabled');
|
||||
expect(devResult.features['platform.leaderboards']).toBe('enabled');
|
||||
|
||||
// All auth features should be enabled
|
||||
expect(devResult.features['auth.signup']).toBe('enabled');
|
||||
expect(devResult.features['auth.login']).toBe('enabled');
|
||||
expect(devResult.features['auth.forgotPassword']).toBe('enabled');
|
||||
expect(devResult.features['auth.resetPassword']).toBe('enabled');
|
||||
|
||||
// Onboarding should be enabled
|
||||
expect(devResult.features['onboarding.wizard']).toBe('enabled');
|
||||
});
|
||||
|
||||
it('should return flattened features with expected structure', async () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
// Verify the result has the expected shape for API response
|
||||
expect(result).toHaveProperty('features');
|
||||
expect(result).toHaveProperty('loadedFrom');
|
||||
expect(result).toHaveProperty('configPath');
|
||||
|
||||
// Verify features is a flat object with dot-notation keys
|
||||
expect(typeof result.features).toBe('object');
|
||||
expect(result.features['platform.dashboard']).toBeDefined();
|
||||
expect(result.features['sponsors.portal']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export class RaceDetailPresenter implements UseCaseOutputPort<GetRaceDetailResul
|
||||
track: output.race.track,
|
||||
car: output.race.car,
|
||||
scheduledAt: output.race.scheduledAt.toISOString(),
|
||||
sessionType: output.race.sessionType.toString(),
|
||||
sessionType: output.race.sessionType.props,
|
||||
status: output.race.status.toString(),
|
||||
strengthOfField: output.race.strengthOfField?.toNumber() ?? null,
|
||||
...(output.race.registeredCount !== undefined && {
|
||||
|
||||
79
apps/api/src/features/features.controller.test.ts
Normal file
79
apps/api/src/features/features.controller.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FeaturesController } from './features.controller';
|
||||
import type { FlattenedFeatures, ConfigLoadResult } from '../config/feature-types';
|
||||
|
||||
// Mock the feature-loader module
|
||||
vi.mock('../config/feature-loader', () => ({
|
||||
loadFeatureConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadFeatureConfig } from '../config/feature-loader';
|
||||
|
||||
describe('FeaturesController', () => {
|
||||
let controller: FeaturesController;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = new FeaturesController();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return features with correct shape', async () => {
|
||||
const mockFeatures: FlattenedFeatures = {
|
||||
'platform.dashboard': 'enabled',
|
||||
'platform.leagues': 'enabled',
|
||||
'sponsors.portal': 'enabled',
|
||||
};
|
||||
|
||||
const mockResult: ConfigLoadResult = {
|
||||
features: mockFeatures,
|
||||
loadedFrom: 'config-file',
|
||||
configPath: 'apps/api/src/config/features.config.ts',
|
||||
};
|
||||
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getFeatures();
|
||||
|
||||
expect(loadFeatureConfig).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveProperty('features');
|
||||
expect(result).toHaveProperty('loadedFrom');
|
||||
expect(result).toHaveProperty('timestamp');
|
||||
expect(result.features).toEqual(mockFeatures);
|
||||
expect(result.loadedFrom).toBe('config-file');
|
||||
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should handle empty features', async () => {
|
||||
const mockResult: ConfigLoadResult = {
|
||||
features: {},
|
||||
loadedFrom: 'defaults',
|
||||
};
|
||||
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getFeatures();
|
||||
|
||||
expect(result.features).toEqual({});
|
||||
expect(result.loadedFrom).toBe('defaults');
|
||||
expect(typeof result.timestamp).toBe('string');
|
||||
});
|
||||
|
||||
it('should include timestamp as ISO string', async () => {
|
||||
const mockResult: ConfigLoadResult = {
|
||||
features: { 'test.feature': 'enabled' as const },
|
||||
loadedFrom: 'config-file',
|
||||
};
|
||||
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await controller.getFeatures();
|
||||
const timestamp = new Date(result.timestamp);
|
||||
|
||||
expect(timestamp instanceof Date).toBe(true);
|
||||
expect(timestamp.toISOString()).toBe(result.timestamp);
|
||||
});
|
||||
});
|
||||
18
apps/api/src/features/features.controller.ts
Normal file
18
apps/api/src/features/features.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from '../domain/auth/Public';
|
||||
import { loadFeatureConfig } from '../config/feature-loader';
|
||||
|
||||
@Controller('features')
|
||||
export class FeaturesController {
|
||||
@Public()
|
||||
@Get()
|
||||
async getFeatures() {
|
||||
const result = await loadFeatureConfig();
|
||||
|
||||
return {
|
||||
features: result.features,
|
||||
loadedFrom: result.loadedFrom,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
102
apps/api/src/features/features.http.test.ts
Normal file
102
apps/api/src/features/features.http.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FeaturesModule } from './features.module';
|
||||
|
||||
// Mock the feature-loader to control test behavior
|
||||
vi.mock('../config/feature-loader', () => ({
|
||||
loadFeatureConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loadFeatureConfig } from '../config/feature-loader';
|
||||
|
||||
describe('Features HTTP Endpoint', () => {
|
||||
let app: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [FeaturesModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('GET /features returns 200 with correct shape', async () => {
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue({
|
||||
features: {
|
||||
'platform.dashboard': 'enabled',
|
||||
'platform.leagues': 'enabled',
|
||||
'sponsors.portal': 'enabled',
|
||||
},
|
||||
loadedFrom: 'config-file',
|
||||
configPath: 'apps/api/src/config/features.config.ts',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/features').expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('features');
|
||||
expect(response.body).toHaveProperty('loadedFrom');
|
||||
expect(response.body).toHaveProperty('timestamp');
|
||||
|
||||
expect(response.body.features).toEqual({
|
||||
'platform.dashboard': 'enabled',
|
||||
'platform.leagues': 'enabled',
|
||||
'sponsors.portal': 'enabled',
|
||||
});
|
||||
expect(response.body.loadedFrom).toBe('config-file');
|
||||
expect(response.body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('GET /features returns 200 with empty features', async () => {
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue({
|
||||
features: {},
|
||||
loadedFrom: 'defaults',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/features').expect(200);
|
||||
|
||||
expect(response.body.features).toEqual({});
|
||||
expect(response.body.loadedFrom).toBe('defaults');
|
||||
expect(typeof response.body.timestamp).toBe('string');
|
||||
});
|
||||
|
||||
it('GET /features works without authentication', async () => {
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue({
|
||||
features: { 'test.feature': 'enabled' as const },
|
||||
loadedFrom: 'config-file',
|
||||
});
|
||||
|
||||
// Should work without any auth headers
|
||||
const response = await request(app.getHttpServer()).get('/features').expect(200);
|
||||
|
||||
expect(response.body.features).toHaveProperty('test.feature');
|
||||
});
|
||||
|
||||
it('GET /features includes timestamp as ISO string', async () => {
|
||||
const beforeRequest = new Date();
|
||||
|
||||
vi.mocked(loadFeatureConfig).mockResolvedValue({
|
||||
features: { 'platform.dashboard': 'enabled' as const },
|
||||
loadedFrom: 'config-file',
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/features').expect(200);
|
||||
const afterRequest = new Date();
|
||||
|
||||
const timestamp = new Date(response.body.timestamp);
|
||||
expect(timestamp instanceof Date).toBe(true);
|
||||
expect(timestamp.toISOString()).toBe(response.body.timestamp);
|
||||
|
||||
// Timestamp should be within the request window
|
||||
expect(timestamp >= beforeRequest).toBe(true);
|
||||
expect(timestamp <= afterRequest).toBe(true);
|
||||
});
|
||||
});
|
||||
8
apps/api/src/features/features.module.ts
Normal file
8
apps/api/src/features/features.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeaturesController } from './features.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [FeaturesController],
|
||||
exports: [],
|
||||
})
|
||||
export class FeaturesModule {}
|
||||
Reference in New Issue
Block a user