Skip to content

Commit 2df4478

Browse files
committed
feat(backend): add feature flag system with usage tracking and gradual rollout (#78)
Implements backend/src/config/featureFlags.ts as specified, plus middleware and admin routes to complete the feature. Define feature flags FeatureFlagRegistry holds 8 typed flags (ai-verification, bulk-verification, batch-operations, job-scheduling, message-queue, rate-limit-tiering, sla-tracking, response-caching) with descriptions, defaults, and rollout strategy. Toggle via config Each flag reads a FEATURE_<NAME> env var at startup: true → force-enable (strategy: all) false → force-disable (strategy: none) 25% → percentage rollout Runtime overrides via featureFlags.override() and featureFlags.reset(). Admin REST API: GET/PATCH/POST /api/v1/flags/:name. Track usage Every featureFlags.evaluate() call increments totalEvaluations, enabledCount, disabledCount, and records lastEvaluatedAt on the in-memory flag state — visible via GET /api/v1/flags. Gradual rollout strategy: 'percentage' uses a stable FNV-1a hash of the caller identifier (X-User-Id > Authorization > X-Api-Key > IP) so the same caller always gets the same result across requests. strategy: 'allowlist' restricts to an explicit set of identifiers. New files: backend/src/config/featureFlags.ts — core registry (singleton) backend/src/middleware/requireFlag.ts — route-gating middleware backend/src/routes/flags.ts — admin API endpoints backend/src/config/__tests__/featureFlags.test.ts — 23 unit tests Tests: 23 passing (vi.resetModules per test to isolate env-var reads). Closes #78
1 parent 184010e commit 2df4478

5 files changed

Lines changed: 803 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* featureFlags.test.ts
3+
*
4+
* Unit tests for the FeatureFlagRegistry:
5+
* - Flag definitions and defaults
6+
* - Env-var override parsing (true / false / N%)
7+
* - evaluate() — all strategies (all, none, percentage, allowlist)
8+
* - Usage tracking (counters, lastEvaluatedAt)
9+
* - override() — force enable/disable, rollout %, allowlist
10+
* - reset() — restores to default
11+
* - Gradual rollout consistency (same identifier always same result)
12+
*/
13+
14+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15+
16+
// ─── Re-usable registry factory ───────────────────────────────────────────────
17+
// We import the module fresh inside each test via dynamic import so that
18+
// environment-variable reads (done once at module load) can be varied.
19+
20+
async function loadRegistry(envOverrides: Record<string, string> = {}) {
21+
// Apply env overrides before the module loads
22+
for (const [k, v] of Object.entries(envOverrides)) {
23+
process.env[k] = v;
24+
}
25+
26+
// Force a fresh module (clear Vitest's module cache)
27+
vi.resetModules();
28+
const { featureFlags } = await import('../featureFlags.js');
29+
30+
return featureFlags;
31+
}
32+
33+
function cleanup(envKeys: string[]) {
34+
for (const k of envKeys) delete process.env[k];
35+
}
36+
37+
// ─── Tests ────────────────────────────────────────────────────────────────────
38+
39+
describe('Flag definitions', () => {
40+
it('registers all expected flags', async () => {
41+
const ff = await loadRegistry();
42+
const names = ff.getAll().map((f) => f.definition.name);
43+
44+
expect(names).toContain('ai-verification');
45+
expect(names).toContain('bulk-verification');
46+
expect(names).toContain('batch-operations');
47+
expect(names).toContain('job-scheduling');
48+
expect(names).toContain('message-queue');
49+
expect(names).toContain('rate-limit-tiering');
50+
expect(names).toContain('sla-tracking');
51+
expect(names).toContain('response-caching');
52+
});
53+
54+
it('ai-verification is enabled by default', async () => {
55+
const ff = await loadRegistry();
56+
expect(ff.evaluate('ai-verification')).toBe(true);
57+
});
58+
59+
it('get() returns null for unknown flags', async () => {
60+
const ff = await loadRegistry();
61+
expect(ff.get('unknown-flag' as any)).toBeNull();
62+
});
63+
64+
it('evaluate() returns false and logs warning for unknown flags', async () => {
65+
const ff = await loadRegistry();
66+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
67+
const result = ff.evaluate('unknown-flag' as any);
68+
expect(result).toBe(false);
69+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown-flag'));
70+
warn.mockRestore();
71+
});
72+
});
73+
74+
describe('Environment variable override', () => {
75+
afterEach(() => cleanup(['FEATURE_AI_VERIFICATION', 'FEATURE_BULK_VERIFICATION', 'FEATURE_RATE_LIMIT_TIERING']));
76+
77+
it('FEATURE_<NAME>=true force-enables a flag', async () => {
78+
const ff = await loadRegistry({ FEATURE_AI_VERIFICATION: 'true' });
79+
expect(ff.evaluate('ai-verification')).toBe(true);
80+
expect(ff.get('ai-verification')!.currentStrategy).toBe('all');
81+
expect(ff.get('ai-verification')!.overridden).toBe(true);
82+
});
83+
84+
it('FEATURE_<NAME>=false force-disables a flag', async () => {
85+
const ff = await loadRegistry({ FEATURE_AI_VERIFICATION: 'false' });
86+
expect(ff.evaluate('ai-verification')).toBe(false);
87+
expect(ff.get('ai-verification')!.currentStrategy).toBe('none');
88+
});
89+
90+
it('FEATURE_<NAME>=50% sets percentage rollout', async () => {
91+
const ff = await loadRegistry({ FEATURE_BULK_VERIFICATION: '50%' });
92+
const state = ff.get('bulk-verification')!;
93+
expect(state.currentStrategy).toBe('percentage');
94+
expect(state.currentRolloutPercentage).toBe(50);
95+
});
96+
97+
it('unrecognised env value falls back to default and logs warning', async () => {
98+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
99+
const ff = await loadRegistry({ FEATURE_RATE_LIMIT_TIERING: 'maybe' });
100+
expect(ff.evaluate('rate-limit-tiering')).toBe(true); // default is true
101+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('FEATURE_RATE_LIMIT_TIERING'));
102+
warn.mockRestore();
103+
});
104+
});
105+
106+
describe('evaluate() — strategies', () => {
107+
it('strategy=all returns true regardless of identifier', async () => {
108+
const ff = await loadRegistry();
109+
expect(ff.evaluate('ai-verification', 'user-A')).toBe(true);
110+
expect(ff.evaluate('ai-verification', 'user-B')).toBe(true);
111+
expect(ff.evaluate('ai-verification', 'anon')).toBe(true);
112+
});
113+
114+
it('strategy=none returns false regardless of identifier', async () => {
115+
const ff = await loadRegistry();
116+
ff.override('ai-verification', { enabled: false });
117+
expect(ff.evaluate('ai-verification', 'user-A')).toBe(false);
118+
expect(ff.evaluate('ai-verification', 'premium')).toBe(false);
119+
});
120+
121+
it('strategy=percentage 0% disables for all callers', async () => {
122+
const ff = await loadRegistry();
123+
ff.override('response-caching', { rolloutPercentage: 0 });
124+
for (let i = 0; i < 50; i++) {
125+
expect(ff.evaluate('response-caching', `user-${i}`)).toBe(false);
126+
}
127+
});
128+
129+
it('strategy=percentage 100% enables for all callers', async () => {
130+
const ff = await loadRegistry();
131+
ff.override('response-caching', { rolloutPercentage: 100 });
132+
for (let i = 0; i < 50; i++) {
133+
expect(ff.evaluate('response-caching', `user-${i}`)).toBe(true);
134+
}
135+
});
136+
137+
it('strategy=percentage ~50% enables roughly half', async () => {
138+
const ff = await loadRegistry();
139+
ff.override('bulk-verification', { rolloutPercentage: 50 });
140+
141+
const enabled = Array.from({ length: 200 }, (_, i) =>
142+
ff.evaluate('bulk-verification', `user-${i}`),
143+
).filter(Boolean).length;
144+
145+
// Should be between 30% and 70% (statistical tolerance)
146+
expect(enabled).toBeGreaterThan(60);
147+
expect(enabled).toBeLessThan(140);
148+
});
149+
150+
it('strategy=percentage is hash-stable (same id always same result)', async () => {
151+
const ff = await loadRegistry();
152+
ff.override('bulk-verification', { rolloutPercentage: 30 });
153+
154+
const id = 'stable-user-id-xyz';
155+
const first = ff.evaluate('bulk-verification', id);
156+
for (let i = 0; i < 20; i++) {
157+
expect(ff.evaluate('bulk-verification', id)).toBe(first);
158+
}
159+
});
160+
161+
it('strategy=allowlist enables only listed identifiers', async () => {
162+
const ff = await loadRegistry();
163+
ff.override('ai-verification', { allowlist: ['alice', 'bob'] });
164+
165+
expect(ff.evaluate('ai-verification', 'alice')).toBe(true);
166+
expect(ff.evaluate('ai-verification', 'bob')).toBe(true);
167+
expect(ff.evaluate('ai-verification', 'charlie')).toBe(false);
168+
expect(ff.evaluate('ai-verification', '')).toBe(false);
169+
});
170+
});
171+
172+
describe('Usage tracking', () => {
173+
it('increments totalEvaluations on each call', async () => {
174+
const ff = await loadRegistry();
175+
ff.evaluate('catalog' as any); // unknown → still increments in wrapper path
176+
ff.evaluate('ai-verification');
177+
ff.evaluate('ai-verification');
178+
ff.evaluate('ai-verification');
179+
180+
const state = ff.get('ai-verification')!;
181+
expect(state.usage.totalEvaluations).toBe(3);
182+
});
183+
184+
it('increments enabledCount / disabledCount correctly', async () => {
185+
const ff = await loadRegistry();
186+
ff.override('bulk-verification', { rolloutPercentage: 50 });
187+
188+
let en = 0, dis = 0;
189+
for (let i = 0; i < 100; i++) {
190+
ff.evaluate('bulk-verification', `u${i}`) ? en++ : dis++;
191+
}
192+
193+
const stats = ff.get('bulk-verification')!.usage;
194+
expect(stats.totalEvaluations).toBe(100);
195+
expect(stats.enabledCount).toBe(en);
196+
expect(stats.disabledCount).toBe(dis);
197+
expect(stats.enabledCount + stats.disabledCount).toBe(100);
198+
});
199+
200+
it('records lastEvaluatedAt as an ISO timestamp after first call', async () => {
201+
const ff = await loadRegistry();
202+
expect(ff.get('sla-tracking')!.usage.lastEvaluatedAt).toBeNull();
203+
204+
ff.evaluate('sla-tracking');
205+
206+
const ts = ff.get('sla-tracking')!.usage.lastEvaluatedAt;
207+
expect(ts).not.toBeNull();
208+
expect(() => new Date(ts!)).not.toThrow();
209+
});
210+
});
211+
212+
describe('override() and reset()', () => {
213+
it('override() force-enables a flag', async () => {
214+
const ff = await loadRegistry();
215+
ff.override('batch-operations', { enabled: false });
216+
expect(ff.evaluate('batch-operations')).toBe(false);
217+
218+
ff.override('batch-operations', { enabled: true });
219+
expect(ff.evaluate('batch-operations')).toBe(true);
220+
expect(ff.get('batch-operations')!.overridden).toBe(true);
221+
});
222+
223+
it('override() throws for unknown flag', async () => {
224+
const ff = await loadRegistry();
225+
expect(() => ff.override('ghost' as any, { enabled: true })).toThrow();
226+
});
227+
228+
it('override() throws when rolloutPercentage is out of range', async () => {
229+
const ff = await loadRegistry();
230+
expect(() => ff.override('ai-verification', { rolloutPercentage: 101 })).toThrow();
231+
expect(() => ff.override('ai-verification', { rolloutPercentage: -1 })).toThrow();
232+
});
233+
234+
it('reset() restores flag to default strategy', async () => {
235+
const ff = await loadRegistry();
236+
ff.override('ai-verification', { enabled: false });
237+
expect(ff.evaluate('ai-verification')).toBe(false);
238+
239+
ff.reset('ai-verification');
240+
expect(ff.evaluate('ai-verification')).toBe(true); // default is true / strategy=all
241+
expect(ff.get('ai-verification')!.overridden).toBe(false);
242+
});
243+
244+
it('reset() on unknown flag is a no-op (does not throw)', async () => {
245+
const ff = await loadRegistry();
246+
expect(() => ff.reset('ghost' as any)).not.toThrow();
247+
});
248+
});

0 commit comments

Comments
 (0)