import { pino } from 'pino'; import { FLAG_EXPOSURE_EVENT } from '../../sdk/events.js'; import { DefaultFlag, Flag } from '../../sdk/models.js'; import { FlagsmithConfig } from '../../sdk/types.js'; import { getUserAgent } from '../../sdk/utils.js'; import { SDK_VERSION } from '../../sdk/version.js'; import { fetch, fetchImpl, identitiesJSON } from './fetchMock.js'; import { flagsmith, postedEvents, TestCache } from './utils.js'; import { readFileSync } from 'fs'; const environmentWithExperimentJSON = readFileSync( __dirname + '/data/environment-with-experiment.json', 'utf-8' ); vi.mock('../../sdk/polling_manager'); const isEsmBuild = process.env.ESM_BUILD === 'true'; /** A client with the events pipeline enabled and its flush timer disabled. */ function experimentsFlagsmith(params: FlagsmithConfig = {}) { return flagsmith({ enableEvents: true, ...params, eventProcessorConfig: { flushInterval: 0, ...params.eventProcessorConfig } }); } test('eventProcessorConfig without enableEvents throws at construction', () => { expect(() => flagsmith({ eventProcessorConfig: { maxBuffer: 10 } })).toThrow( 'ValueError: eventProcessorConfig requires enableEvents: true.' ); }); test('getExperimentFlag throws when events are disabled', async () => { await expect(flagsmith().getExperimentFlag('some_feature', 'identifier')).rejects.toThrow( 'ValueError: enableEvents must be true to use getExperimentFlag.' ); }); test('trackEvent throws when events are disabled', () => { expect(() => flagsmith().trackEvent('purchase')).toThrow( 'ValueError: enableEvents must be true to track events.' ); }); test('trackExposureEvent throws when events are disabled', () => { expect(() => flagsmith().trackExposureEvent('some_feature', { identifier: 'identifier' }) ).toThrow('ValueError: enableEvents must be true to track events.'); }); test('flushEvents resolves when events are disabled', async () => { await expect(flagsmith().flushEvents()).resolves.toBeUndefined(); expect(postedEvents()).toHaveLength(0); }); test('events are also disabled in offline mode', async () => { const flg = flagsmith({ offlineMode: true, offlineHandler: { getEnvironment: () => ({}) } as any, environmentKey: undefined, enableEvents: true }); expect(() => flg.trackEvent('purchase')).toThrow( 'ValueError: enableEvents must be true to track events.' ); }); test.each([FLAG_EXPOSURE_EVENT, '$purchase'])( 'trackEvent rejects the reserved event name %s', event => { expect(() => experimentsFlagsmith().trackEvent(event)).toThrow( `ValueError: event names starting with "$" are reserved; use trackExposureEvent to record "${FLAG_EXPOSURE_EVENT}".` ); } ); test('trackEvent records a custom event', async () => { const flg = experimentsFlagsmith(); flg.trackEvent('purchase', { identifier: 'user-123', value: 49, metadata: { currency: 'GBP' } }); await flg.flushEvents(); expect(postedEvents()).toEqual([ expect.objectContaining({ event: 'purchase', feature_name: null, identifier: 'user-123', value: '49', traits: null, metadata: { currency: 'GBP', sdk_version: SDK_VERSION } }) ]); }); test('trackEvent unwraps TraitConfig values', async () => { const flg = experimentsFlagsmith(); flg.trackEvent('purchase', { identifier: 'user-123', traits: { plan: 'premium', age: { value: 30, transient: true } } }); await flg.flushEvents(); expect(postedEvents()[0].traits).toEqual({ plan: 'premium', age: 30 }); }); test('trackExposureEvent without an identifier logs and sends nothing', async () => { const logger = pino({ level: 'silent' }); const warn = vi.spyOn(logger, 'warn'); const flg = experimentsFlagsmith({ logger }); flg.trackExposureEvent('some_feature', { identifier: '' }); await flg.flushEvents(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('requires an identifier')); expect(postedEvents()).toHaveLength(0); }); test('getExperimentFlag records an exposure for an enrolled identity', async () => { const flg = experimentsFlagsmith(); const flag = (await flg.getExperimentFlag('some_feature', 'user-123')) as Flag; expect(flag.value).toBe('some-value'); expect(flag.variant).toBe('treatment'); expect(flag.reason).toBe('SPLIT; weight=70.0'); expect(flag.experiment).toEqual({ id: 167, name: 'some_experiment', inExperiment: true }); await flg.flushEvents(); expect(postedEvents()).toEqual([ expect.objectContaining({ event: FLAG_EXPOSURE_EVENT, feature_name: 'some_feature', identifier: 'user-123', value: 'treatment', metadata: { experiment_id: 167, sdk_version: SDK_VERSION } }) ]); }); // Skip in ESM build: instanceof fails across module boundaries test.skipIf(isEsmBuild)('getExperimentFlag returns a Flag for an enrolled identity', async () => { const flg = experimentsFlagsmith(); expect(await flg.getExperimentFlag('some_feature', 'user-123')).toBeInstanceOf(Flag); }); test('getExperimentFlag sends the resolved traits with the exposure', async () => { const flg = experimentsFlagsmith(); await flg.getExperimentFlag('some_feature', 'user-123', { plan: 'premium', age: { value: 30, transient: true } }); await flg.flushEvents(); expect(postedEvents()[0].traits).toEqual({ plan: 'premium', age: 30 }); }); test.each([ ['the identity is outside the rollout', 'not_enrolled_feature'], ['the feature has no experiment metadata', 'no_experiment_feature'], ['the feature is disabled', 'disabled_experiment_feature'], ['the feature was not found', 'missing_feature'] ])('getExperimentFlag records no exposure when %s', async (_name, featureName) => { const flg = experimentsFlagsmith(); await flg.getExperimentFlag(featureName, 'user-123'); await flg.flushEvents(); expect(postedEvents()).toHaveLength(0); }); test('getExperimentFlag records no exposure when the flag has no variant', async () => { const identities = JSON.parse(identitiesJSON); delete identities.flags.find((f: any) => f.feature.name === 'some_feature').variant; fetch.mockImplementation((url, options) => String(url).includes('/identities') ? Promise.resolve(new Response(JSON.stringify(identities), { status: 200 })) : fetchImpl(url, options) ); const flg = experimentsFlagsmith(); const flag = (await flg.getExperimentFlag('some_feature', 'user-123')) as Flag; await flg.flushEvents(); expect(flag.experiment?.inExperiment).toBe(true); expect(flag.variant).toBeUndefined(); expect(postedEvents()).toHaveLength(0); }); test('getExperimentFlag still returns the flag when the identity is outside the rollout', async () => { const flg = experimentsFlagsmith(); const flag = (await flg.getExperimentFlag('not_enrolled_feature', 'user-123')) as Flag; expect(flag.variant).toBe('control'); expect(flag.experiment?.inExperiment).toBe(false); }); test('getExperimentFlag records no exposure for a feature served by the default flag handler', async () => { const flg = experimentsFlagsmith({ defaultFlagHandler: () => new DefaultFlag('some-default-value', true) }); const flag = await flg.getExperimentFlag('missing_feature', 'user-123'); await flg.flushEvents(); expect(flag.isDefault).toBe(true); expect(flag.value).toBe('some-default-value'); expect(postedEvents()).toHaveLength(0); }); /** * A locally evaluating client whose environment document runs an experiment on `checkout`. Its * rollout segment is identities on the premium plan, who are served the `treatment` variant. * `user_overridden` has an identity override for `checkout`, and `plain` has no experiment. */ function localExperimentsFlagsmith() { fetch.mockImplementation((url, options) => String(url).includes('/environment-document') ? Promise.resolve(new Response(environmentWithExperimentJSON, { status: 200 })) : fetchImpl(url, options) ); return experimentsFlagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true }); } test('getExperimentFlag records an exposure for an enrolled identity when evaluating locally', async () => { const flg = localExperimentsFlagsmith(); const flag = (await flg.getExperimentFlag('checkout', 'user-123', { plan: 'premium' })) as Flag; await flg.flushEvents(); expect(flag.value).toBe('new checkout'); expect(flag.variant).toBe('treatment'); expect(flag.experiment).toEqual({ id: 42, name: 'Checkout experiment', inExperiment: true }); expect(postedEvents()).toEqual([ expect.objectContaining({ event: FLAG_EXPOSURE_EVENT, feature_name: 'checkout', identifier: 'user-123', value: 'treatment', traits: { plan: 'premium' }, metadata: { experiment_id: 42, sdk_version: SDK_VERSION } }) ]); }); test.each([ [ 'the identity is outside the rollout', 'checkout', 'user-123', 'free', 'control', { id: 42, name: 'Checkout experiment', inExperiment: false } ], [ 'the identity has an identity override', 'checkout', 'user_overridden', 'premium', 'overridden checkout', undefined ], ['the feature has no experiment', 'plain', 'user-123', 'premium', 'hello', undefined] ])( 'getExperimentFlag records no exposure when evaluating locally and %s', async (_name, featureName, identifier, plan, expectedValue, expectedExperiment) => { const flg = localExperimentsFlagsmith(); const flag = (await flg.getExperimentFlag(featureName, identifier, { plan })) as Flag; await flg.flushEvents(); expect(flag.value).toBe(expectedValue); expect(flag.experiment).toEqual(expectedExperiment); expect(postedEvents()).toHaveLength(0); } ); test('environment flags carry no experiment when evaluating locally', async () => { const flg = localExperimentsFlagsmith(); const flag = (await flg.getEnvironmentFlags()).getFlag('checkout') as Flag; expect(flag.value).toBe('control'); expect(flag.experiment).toBeUndefined(); }); test('getExperimentFlag records one exposure per identity', async () => { const flg = experimentsFlagsmith(); await flg.getExperimentFlag('some_feature', 'user-123'); await flg.getExperimentFlag('some_feature', 'user-456'); await flg.flushEvents(); expect(postedEvents().map(event => event.identifier)).toEqual(['user-123', 'user-456']); }); test('getExperimentFlag deduplicates repeated exposures of the same identity', async () => { const flg = experimentsFlagsmith(); await flg.getExperimentFlag('some_feature', 'user-123'); await flg.getExperimentFlag('some_feature', 'user-123'); await flg.flushEvents(); expect(postedEvents()).toHaveLength(1); }); test('getExperimentFlag records an exposure for flags served from the identity cache', async () => { const flg = experimentsFlagsmith({ cache: new TestCache() }); await flg.getExperimentFlag('some_feature', 'user-123'); await flg.flushEvents(); await flg.getExperimentFlag('some_feature', 'user-123'); await flg.flushEvents(); const identityRequests = fetch.mock.calls.filter(([url]) => String(url).includes('/identities') ); expect(identityRequests).toHaveLength(1); expect(postedEvents().map(event => event.identifier)).toEqual(['user-123', 'user-123']); }); test('close flushes the buffered events', async () => { const flg = experimentsFlagsmith(); flg.trackEvent('purchase', { identifier: 'user-123' }); await flg.close(); expect(postedEvents()).toHaveLength(1); }); test('the events request carries the environment key and the SDK user agent headers', async () => { const flg = experimentsFlagsmith(); flg.trackEvent('purchase', { identifier: 'user-123' }); await flg.flushEvents(); const [url, options] = fetch.mock.calls.find(([url]) => String(url).includes('/v1/events'))!; expect(url).toBe('https://events.api.flagsmith.com/v1/events'); expect((options?.headers as Record)['X-Environment-Key']).toBe( 'sometestfakekey' ); expect(JSON.parse(String(options?.body))).not.toHaveProperty('environment_key'); }); test('close is terminal: events tracked afterwards are not sent', async () => { const flg = experimentsFlagsmith(); await flg.close(); flg.trackEvent('purchase', { identifier: 'user-123' }); await flg.flushEvents(); expect(postedEvents()).toHaveLength(0); }); test('eventProcessorConfig is passed to the event processor', async () => { const flg = flagsmith({ enableEvents: true, eventProcessorConfig: { eventsApiUrl: 'https://events.example.com', maxBuffer: 1 } }); // An event tracked while the buffer is full flushes it. flg.trackEvent('purchase', { identifier: 'user-123' }); flg.trackEvent('purchase', { identifier: 'user-456' }); expect(fetch).toHaveBeenCalledWith( 'https://events.example.com/v1/events', expect.objectContaining({ method: 'POST' }) ); expect(postedEvents().map(event => event.identifier)).toEqual(['user-123']); await flg.close(); }); test('the events request inherits the client agent and custom headers', async () => { const agent = { name: 'test-dispatcher' } as any; const flg = experimentsFlagsmith({ agent, customHeaders: { 'X-Proxy-Token': 'secret' } }); flg.trackEvent('purchase', { identifier: 'user-123' }); await flg.flushEvents(); const [, options] = fetch.mock.calls.find(([url]) => String(url).includes('/v1/events'))!; expect(options).toMatchObject({ dispatcher: agent, headers: expect.objectContaining({ 'X-Proxy-Token': 'secret', 'X-Environment-Key': 'sometestfakekey', 'Flagsmith-SDK-User-Agent': getUserAgent() }) }); });