feature flags
This commit is contained in:
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