feature flags
This commit is contained in:
@@ -1,95 +1,143 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { FeatureFlagService, MockFeatureFlagService, mockFeatureFlags } from './FeatureFlagService';
|
||||
|
||||
describe('FeatureFlagService', () => {
|
||||
describe('fromEnv() with alpha mode integration', () => {
|
||||
let originalMode: string | undefined;
|
||||
let originalFlags: string | undefined;
|
||||
describe('fromAPI()', () => {
|
||||
let originalBaseUrl: string | undefined;
|
||||
let fetchMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalMode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
|
||||
originalFlags = process.env.FEATURE_FLAGS;
|
||||
originalBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
// Mock fetch globally
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalMode !== undefined) {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = originalMode;
|
||||
if (originalBaseUrl !== undefined) {
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL = originalBaseUrl;
|
||||
} else {
|
||||
delete process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
|
||||
}
|
||||
|
||||
if (originalFlags !== undefined) {
|
||||
process.env.FEATURE_FLAGS = originalFlags;
|
||||
} else {
|
||||
delete process.env.FEATURE_FLAGS;
|
||||
delete process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should enable all features when NEXT_PUBLIC_GRIDPILOT_MODE is alpha', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'alpha';
|
||||
|
||||
const service = FeatureFlagService.fromEnv();
|
||||
it('should fetch from API and enable flags with value "enabled"', async () => {
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL = 'http://api.example.com';
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: {
|
||||
driver_profiles: 'enabled',
|
||||
team_profiles: 'enabled',
|
||||
wallets: 'disabled',
|
||||
sponsors: 'enabled',
|
||||
team_feature: 'disabled',
|
||||
alpha_features: 'enabled'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://api.example.com/features',
|
||||
{ next: { revalidate: 0 } }
|
||||
);
|
||||
expect(service.isEnabled('driver_profiles')).toBe(true);
|
||||
expect(service.isEnabled('team_profiles')).toBe(true);
|
||||
expect(service.isEnabled('wallets')).toBe(true);
|
||||
expect(service.isEnabled('sponsors')).toBe(true);
|
||||
expect(service.isEnabled('team_feature')).toBe(true);
|
||||
expect(service.isEnabled('alpha_features')).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable no features when NEXT_PUBLIC_GRIDPILOT_MODE is pre-launch', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'pre-launch';
|
||||
|
||||
const service = FeatureFlagService.fromEnv();
|
||||
|
||||
expect(service.isEnabled('driver_profiles')).toBe(false);
|
||||
expect(service.isEnabled('team_profiles')).toBe(false);
|
||||
expect(service.isEnabled('wallets')).toBe(false);
|
||||
expect(service.isEnabled('sponsors')).toBe(false);
|
||||
expect(service.isEnabled('team_feature')).toBe(false);
|
||||
expect(service.isEnabled('alpha_features')).toBe(false);
|
||||
});
|
||||
|
||||
it('should enable no features when NEXT_PUBLIC_GRIDPILOT_MODE is not set', () => {
|
||||
delete process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
|
||||
|
||||
const service = FeatureFlagService.fromEnv();
|
||||
|
||||
expect(service.isEnabled('driver_profiles')).toBe(false);
|
||||
expect(service.isEnabled('team_profiles')).toBe(false);
|
||||
expect(service.isEnabled('wallets')).toBe(false);
|
||||
expect(service.isEnabled('sponsors')).toBe(false);
|
||||
expect(service.isEnabled('team_feature')).toBe(false);
|
||||
expect(service.isEnabled('alpha_features')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow FEATURE_FLAGS to override alpha mode', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'alpha';
|
||||
process.env.FEATURE_FLAGS = 'driver_profiles,wallets';
|
||||
|
||||
const service = FeatureFlagService.fromEnv();
|
||||
|
||||
expect(service.isEnabled('driver_profiles')).toBe(true);
|
||||
expect(service.isEnabled('wallets')).toBe(true);
|
||||
expect(service.isEnabled('team_profiles')).toBe(false);
|
||||
expect(service.isEnabled('sponsors')).toBe(false);
|
||||
expect(service.isEnabled('team_feature')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct list of enabled flags in alpha mode', () => {
|
||||
process.env.NEXT_PUBLIC_GRIDPILOT_MODE = 'alpha';
|
||||
it('should use default localhost URL when NEXT_PUBLIC_API_BASE_URL is not set', async () => {
|
||||
delete process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
const service = FeatureFlagService.fromEnv();
|
||||
const enabledFlags = service.getEnabledFlags();
|
||||
|
||||
expect(enabledFlags).toContain('driver_profiles');
|
||||
expect(enabledFlags).toContain('team_profiles');
|
||||
expect(enabledFlags).toContain('wallets');
|
||||
expect(enabledFlags).toContain('sponsors');
|
||||
expect(enabledFlags).toContain('team_feature');
|
||||
expect(enabledFlags).toContain('alpha_features');
|
||||
expect(enabledFlags.length).toBe(6);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: {
|
||||
alpha_features: 'enabled'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:3001/features',
|
||||
{ next: { revalidate: 0 } }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty flags on HTTP error', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error'
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(service.isEnabled('any_flag')).toBe(false);
|
||||
expect(service.getEnabledFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty flags on network error', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(service.isEnabled('any_flag')).toBe(false);
|
||||
expect(service.getEnabledFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty features object', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ features: {} })
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(service.getEnabledFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle malformed response', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(service.getEnabledFlags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore non-"enabled" values', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: {
|
||||
flag1: 'enabled',
|
||||
flag2: 'disabled',
|
||||
flag3: 'pending',
|
||||
flag4: 'ENABLED', // case sensitive
|
||||
flag5: ''
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const service = await FeatureFlagService.fromAPI();
|
||||
|
||||
expect(service.isEnabled('flag1')).toBe(true);
|
||||
expect(service.isEnabled('flag2')).toBe(false);
|
||||
expect(service.isEnabled('flag3')).toBe(false);
|
||||
expect(service.isEnabled('flag4')).toBe(false);
|
||||
expect(service.isEnabled('flag5')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* FeatureFlagService - Manages feature flags for both server and client
|
||||
*
|
||||
* Automatic Alpha Mode Integration:
|
||||
* When NEXT_PUBLIC_GRIDPILOT_MODE=alpha, all features are automatically enabled.
|
||||
* This eliminates the need to manually set FEATURE_FLAGS for alpha deployments.
|
||||
* API-Driven Integration:
|
||||
* Fetches feature flags from the API endpoint GET /features
|
||||
* Returns empty flags on error (secure by default)
|
||||
*
|
||||
* Server: Reads from process.env.FEATURE_FLAGS (comma-separated)
|
||||
* OR auto-enables all features if in alpha mode
|
||||
* Server: Fetches from API at ${NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'}/features
|
||||
* Client: Reads from session context or provides mock implementation
|
||||
*/
|
||||
|
||||
@@ -18,7 +17,7 @@ export class FeatureFlagService {
|
||||
if (flags) {
|
||||
this.flags = new Set(flags);
|
||||
} else {
|
||||
// Parse from environment variable
|
||||
// Parse from environment variable (fallback for backward compatibility)
|
||||
const flagsEnv = process.env.FEATURE_FLAGS;
|
||||
this.flags = flagsEnv
|
||||
? new Set(flagsEnv.split(',').map(f => f.trim()))
|
||||
@@ -41,33 +40,44 @@ export class FeatureFlagService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create service with environment flags
|
||||
* Automatically enables all features if in alpha mode
|
||||
* FEATURE_FLAGS can override alpha mode defaults
|
||||
* Factory method to create service by fetching from API
|
||||
* Fetches from ${NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'}/features
|
||||
* On error, returns empty flags (secure by default)
|
||||
*/
|
||||
static fromEnv(): FeatureFlagService {
|
||||
const mode = process.env.NEXT_PUBLIC_GRIDPILOT_MODE;
|
||||
const flagsEnv = process.env.FEATURE_FLAGS;
|
||||
|
||||
// If FEATURE_FLAGS is explicitly set, use it (overrides alpha mode)
|
||||
if (flagsEnv) {
|
||||
return new FeatureFlagService();
|
||||
static async fromAPI(): Promise<FeatureFlagService> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const url = `${baseUrl}/features`;
|
||||
|
||||
try {
|
||||
// Use next: { revalidate: 0 } for Next.js server runtime
|
||||
// This is equivalent to cache: 'no-store' but is the preferred Next.js convention
|
||||
const response = await fetch(url, {
|
||||
next: { revalidate: 0 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Parse JSON { features: Record<string, string> }
|
||||
// Enable flags whose value is 'enabled'
|
||||
const enabledFlags: string[] = [];
|
||||
if (data.features && typeof data.features === 'object') {
|
||||
Object.entries(data.features).forEach(([flag, value]) => {
|
||||
if (value === 'enabled') {
|
||||
enabledFlags.push(flag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new FeatureFlagService(enabledFlags);
|
||||
} catch (error) {
|
||||
// Log error but return empty flags (secure by default)
|
||||
console.error('Failed to fetch feature flags from API:', error);
|
||||
return new FeatureFlagService([]);
|
||||
}
|
||||
|
||||
// If in alpha mode, automatically enable all features
|
||||
if (mode === 'alpha') {
|
||||
return new FeatureFlagService([
|
||||
'driver_profiles',
|
||||
'team_profiles',
|
||||
'wallets',
|
||||
'sponsors',
|
||||
'team_feature',
|
||||
'alpha_features'
|
||||
]);
|
||||
}
|
||||
|
||||
// Otherwise, use FEATURE_FLAGS environment variable (empty if not set)
|
||||
return new FeatureFlagService();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user