feature flags

This commit is contained in:
2026-01-07 22:05:53 +01:00
parent 1b63fa646c
commit 606b64cec7
530 changed files with 2092 additions and 2943 deletions

View 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);
});
});

View 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(),
};
}
}

View 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);
});
});

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FeaturesController } from './features.controller';
@Module({
controllers: [FeaturesController],
exports: [],
})
export class FeaturesModule {}