Skip to content

Commit cfa6df1

Browse files
committed
test: add unit tests for FlagManager debug override functionality
1 parent ed29a8a commit cfa6df1

File tree

7 files changed

+259
-24
lines changed

7 files changed

+259
-24
lines changed
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { Context, Crypto, Hasher, LDLogger, Platform, Storage } from '@launchdarkly/js-sdk-common';
2+
3+
import DefaultFlagManager from '../../src/flag-manager/FlagManager';
4+
import { FlagsChangeCallback } from '../../src/flag-manager/FlagUpdater';
5+
import { ItemDescriptor } from '../../src/flag-manager/ItemDescriptor';
6+
import { Flag } from '../../src/types';
7+
8+
const TEST_SDK_KEY = 'test-sdk-key';
9+
const TEST_MAX_CACHED_CONTEXTS = 5;
10+
11+
function makeMockPlatform(storage: Storage, crypto: Crypto): Platform {
12+
return {
13+
storage,
14+
crypto,
15+
info: {
16+
platformData: jest.fn(),
17+
sdkData: jest.fn(),
18+
},
19+
requests: {
20+
fetch: jest.fn(),
21+
createEventSource: jest.fn(),
22+
getEventSourceCapabilities: jest.fn(),
23+
},
24+
};
25+
}
26+
27+
function makeMemoryStorage(): Storage {
28+
const data = new Map<string, string>();
29+
return {
30+
get: async (key: string) => {
31+
const value = data.get(key);
32+
return value !== undefined ? value : null;
33+
},
34+
set: async (key: string, value: string) => {
35+
data.set(key, value);
36+
},
37+
clear: async (key: string) => {
38+
data.delete(key);
39+
},
40+
};
41+
}
42+
43+
function makeMockCrypto() {
44+
let counter = 0;
45+
let lastInput = '';
46+
const hasher: Hasher = {
47+
update: jest.fn((input) => {
48+
lastInput = input;
49+
return hasher;
50+
}),
51+
digest: jest.fn(() => `${lastInput}Hashed`),
52+
};
53+
54+
return {
55+
createHash: jest.fn(() => hasher),
56+
createHmac: jest.fn(),
57+
randomUUID: jest.fn(() => {
58+
counter += 1;
59+
return `${counter}`;
60+
}),
61+
};
62+
}
63+
64+
function makeMockLogger(): LDLogger {
65+
return {
66+
error: jest.fn(),
67+
warn: jest.fn(),
68+
info: jest.fn(),
69+
debug: jest.fn(),
70+
};
71+
}
72+
73+
function makeMockFlag(version: number = 1, value: any = 'test-value'): Flag {
74+
return {
75+
version,
76+
flagVersion: version,
77+
value,
78+
variation: 0,
79+
trackEvents: false,
80+
};
81+
}
82+
83+
function makeMockItemDescriptor(version: number = 1, value: any = 'test-value'): ItemDescriptor {
84+
return {
85+
version,
86+
flag: makeMockFlag(version, value),
87+
};
88+
}
89+
90+
describe('FlagManager override tests', () => {
91+
let flagManager: DefaultFlagManager;
92+
let mockPlatform: Platform;
93+
let mockLogger: LDLogger;
94+
95+
beforeEach(() => {
96+
mockLogger = makeMockLogger();
97+
mockPlatform = makeMockPlatform(makeMemoryStorage(), makeMockCrypto());
98+
flagManager = new DefaultFlagManager(
99+
mockPlatform,
100+
TEST_SDK_KEY,
101+
TEST_MAX_CACHED_CONTEXTS,
102+
mockLogger,
103+
);
104+
});
105+
106+
it('setOverride takes precedence over flag store value', async () => {
107+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
108+
const flags = {
109+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
110+
};
111+
112+
await flagManager.init(context, flags);
113+
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');
114+
115+
const debugOverride = flagManager.getDebugOverride();
116+
debugOverride?.setOverride('test-flag', 'override-value');
117+
118+
expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');
119+
});
120+
121+
it('setOverride triggers flag change callback', () => {
122+
const mockCallback: FlagsChangeCallback = jest.fn();
123+
flagManager.on(mockCallback);
124+
125+
const debugOverride = flagManager.getDebugOverride();
126+
debugOverride?.setOverride('test-flag', 'override-value');
127+
128+
expect(mockCallback).toHaveBeenCalledTimes(1);
129+
expect(mockCallback).toHaveBeenCalledWith(null, ['test-flag'], 'override');
130+
});
131+
132+
it('removeOverride does nothing when override does not exist', () => {
133+
const debugOverride = flagManager.getDebugOverride();
134+
expect(() => {
135+
debugOverride?.removeOverride('non-existent-flag');
136+
}).not.toThrow();
137+
});
138+
139+
it('removeOverride reverts to flag store value when override is removed', async () => {
140+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
141+
const flags = {
142+
'test-flag': makeMockItemDescriptor(1, 'store-value'),
143+
};
144+
145+
await flagManager.init(context, flags);
146+
const debugOverride = flagManager.getDebugOverride();
147+
debugOverride?.setOverride('test-flag', 'override-value');
148+
expect(flagManager.get('test-flag')?.flag.value).toBe('override-value');
149+
150+
debugOverride?.removeOverride('test-flag');
151+
expect(flagManager.get('test-flag')?.flag.value).toBe('store-value');
152+
});
153+
154+
it('removeOverride triggers flag change callback', () => {
155+
const mockCallback: FlagsChangeCallback = jest.fn();
156+
flagManager.on(mockCallback);
157+
158+
const debugOverride = flagManager.getDebugOverride();
159+
debugOverride?.setOverride('test-flag', 'override-value');
160+
debugOverride?.removeOverride('test-flag');
161+
162+
expect(mockCallback).toHaveBeenCalledTimes(2);
163+
expect(mockCallback).toHaveBeenNthCalledWith(1, null, ['test-flag'], 'override');
164+
expect(mockCallback).toHaveBeenNthCalledWith(2, null, ['test-flag'], 'override');
165+
});
166+
167+
it('clearAllOverrides removes all overrides', () => {
168+
const debugOverride = flagManager.getDebugOverride();
169+
debugOverride?.setOverride('flag1', 'value1');
170+
debugOverride?.setOverride('flag2', 'value2');
171+
debugOverride?.setOverride('flag3', 'value3');
172+
173+
expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(3);
174+
175+
debugOverride?.clearAllOverrides();
176+
expect(Object.keys(flagManager.getAllOverrides())).toHaveLength(0);
177+
});
178+
179+
it('clearAllOverrides triggers flag change callback for all flags', () => {
180+
const mockCallback: FlagsChangeCallback = jest.fn();
181+
flagManager.on(mockCallback);
182+
183+
const debugOverride = flagManager.getDebugOverride();
184+
debugOverride?.setOverride('flag1', 'value1');
185+
debugOverride?.setOverride('flag2', 'value2');
186+
(mockCallback as jest.Mock).mockClear();
187+
188+
debugOverride?.clearAllOverrides();
189+
expect(mockCallback).toHaveBeenCalledTimes(1);
190+
expect(mockCallback).toHaveBeenCalledWith(null, ['flag1', 'flag2'], 'override');
191+
});
192+
193+
it('getAllOverrides returns all overrides as ItemDescriptors', () => {
194+
const debugOverride = flagManager.getDebugOverride();
195+
debugOverride?.setOverride('flag1', 'value1');
196+
debugOverride?.setOverride('flag2', 42);
197+
debugOverride?.setOverride('flag3', true);
198+
199+
const overrides = debugOverride?.getAllOverrides();
200+
expect(overrides).toHaveProperty('flag1');
201+
expect(overrides).toHaveProperty('flag2');
202+
expect(overrides).toHaveProperty('flag3');
203+
expect(overrides?.flag1.flag.value).toBe('value1');
204+
expect(overrides?.flag2.flag.value).toBe(42);
205+
expect(overrides?.flag3.flag.value).toBe(true);
206+
expect(overrides?.flag1.version).toBe(0);
207+
expect(overrides?.flag2.version).toBe(0);
208+
expect(overrides?.flag3.version).toBe(0);
209+
});
210+
211+
it('getAll merges overrides with flag store values', async () => {
212+
const context = Context.fromLDContext({ kind: 'user', key: 'user-key' });
213+
const flags = {
214+
'store-flag': makeMockItemDescriptor(1, 'store-value'),
215+
'shared-flag': makeMockItemDescriptor(1, 'store-value'),
216+
};
217+
218+
await flagManager.init(context, flags);
219+
const debugOverride = flagManager.getDebugOverride();
220+
debugOverride?.setOverride('shared-flag', 'override-value');
221+
debugOverride?.setOverride('override-only-flag', 'override-value');
222+
223+
const allFlags = flagManager.getAll();
224+
expect(allFlags).toHaveProperty('store-flag');
225+
expect(allFlags).toHaveProperty('shared-flag');
226+
expect(allFlags).toHaveProperty('override-only-flag');
227+
expect(allFlags['store-flag'].flag.value).toBe('store-value');
228+
expect(allFlags['shared-flag'].flag.value).toBe('override-value');
229+
expect(allFlags['override-only-flag'].flag.value).toBe('override-value');
230+
});
231+
});

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
} from './evaluation/evaluationDetail';
4242
import createEventProcessor from './events/createEventProcessor';
4343
import EventFactory from './events/EventFactory';
44-
import DefaultFlagManager, { LDDebugOverride, FlagManager } from './flag-manager/FlagManager';
44+
import DefaultFlagManager, { FlagManager, LDDebugOverride } from './flag-manager/FlagManager';
4545
import { FlagChangeType } from './flag-manager/FlagUpdater';
4646
import HookRunner from './HookRunner';
4747
import { getInspectorHook } from './inspection/getInspectorHook';
@@ -584,10 +584,10 @@ export default class LDClientImpl implements LDClient, LDClientIdentifyResult {
584584

585585
protected getDebugOverrides(): LDDebugOverride | null {
586586
if (this._flagManager.getDebugOverride) {
587-
return this._flagManager.getDebugOverride()
587+
return this._flagManager.getDebugOverride();
588588
}
589589

590-
return null
590+
return null;
591591
}
592592

593593
private _handleInspectionChanged(flagKeys: Array<string>, type: FlagChangeType) {
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { LDPluginBase } from '@launchdarkly/js-sdk-common';
2+
23
import { LDDebugOverride } from '../flag-manager/FlagManager';
34

45
export interface LDPlugin<TClient, THook> extends LDPluginBase<TClient, THook> {
5-
/**
6+
/**
67
* An optional function called if the plugin wants to register debug capabilities.
78
* This method allows plugins to receive a debug override interface for
89
* temporarily overriding flag values during development and testing.
@@ -12,5 +13,5 @@ export interface LDPlugin<TClient, THook> extends LDPluginBase<TClient, THook> {
1213
*
1314
* @param debugOverride The debug override interface instance
1415
*/
15-
registerDebug?(debugOverride: LDDebugOverride): void;
16+
registerDebug?(debugOverride: LDDebugOverride): void;
1617
}

packages/shared/sdk-client/src/api/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ export { ConnectionMode };
99
export * from './LDIdentifyOptions';
1010
export * from './LDInspection';
1111
export * from './LDIdentifyResult';
12-
export * from './LDPlugin';
12+
export * from './LDPlugin';

packages/shared/sdk-client/src/flag-manager/FlagManager.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export interface FlagManager {
6767
*
6868
* @experimental This function is experimental and intended for use by LaunchDarkly tools at this time.
6969
*/
70-
getDebugOverride?(): LDDebugOverride
70+
getDebugOverride?(): LDDebugOverride;
7171
}
7272

7373
/**
@@ -173,14 +173,16 @@ export default class DefaultFlagManager implements FlagManager {
173173
if (this._overrides) {
174174
return {
175175
...this._flagStore.getAll(),
176-
...Object.entries(this._overrides).reduce((acc: {[key: string]: ItemDescriptor}, [key, value]) => {
177-
acc[key] = this._convertValueToOverrideDescripter(value);
178-
return acc
179-
}, {})
180-
}
181-
} else {
182-
return this._flagStore.getAll();
176+
...Object.entries(this._overrides).reduce(
177+
(acc: { [key: string]: ItemDescriptor }, [key, value]) => {
178+
acc[key] = this._convertValueToOverrideDescripter(value);
179+
return acc;
180+
},
181+
{},
182+
),
183+
};
183184
}
185+
return this._flagStore.getAll();
184186
}
185187

186188
setBootstrap(context: Context, newFlags: { [key: string]: ItemDescriptor }): void {
@@ -212,10 +214,10 @@ export default class DefaultFlagManager implements FlagManager {
212214
private _convertValueToOverrideDescripter(value: LDFlagValue): ItemDescriptor {
213215
return {
214216
flag: {
215-
value: value,
216-
version: 0
217+
value,
218+
version: 0,
217219
},
218-
version: 0
220+
version: 0,
219221
};
220222
}
221223

packages/shared/sdk-client/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export type {
4141

4242
export type { DataManager, DataManagerFactory, ConnectionParams } from './DataManager';
4343
export type { FlagManager, LDDebugOverride } from './flag-manager/FlagManager';
44-
export {safeRegisterDebugOverridePlugins} from './plugins/safeRegisterDebugOverridePlugins';
44+
export { safeRegisterDebugOverridePlugins } from './plugins/safeRegisterDebugOverridePlugins';
4545
export type { Configuration } from './configuration/Configuration';
4646

4747
export type { LDEmitter };
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { internal, LDLogger } from "@launchdarkly/js-sdk-common";
2-
import { LDPlugin } from "../api";
3-
import { LDDebugOverride } from "../flag-manager/FlagManager";
1+
import { internal, LDLogger } from '@launchdarkly/js-sdk-common';
2+
3+
import { LDPlugin } from '../api';
4+
import { LDDebugOverride } from '../flag-manager/FlagManager';
45

56
/**
67
* Safe register debug override plugins.
@@ -12,13 +13,13 @@ import { LDDebugOverride } from "../flag-manager/FlagManager";
1213
export function safeRegisterDebugOverridePlugins<TClient, THook>(
1314
logger: LDLogger,
1415
debugOverride: LDDebugOverride,
15-
plugins: LDPlugin<TClient, THook>[]
16+
plugins: LDPlugin<TClient, THook>[],
1617
): void {
17-
plugins.forEach(plugin => {
18+
plugins.forEach((plugin) => {
1819
try {
1920
plugin.registerDebug?.(debugOverride);
2021
} catch (error) {
2122
logger.error(`Exception thrown registering plugin ${internal.safeGetName(logger, plugin)}.`);
2223
}
2324
});
24-
};
25+
}

0 commit comments

Comments
 (0)