UNPKG

flagsmith-nodejs

Version:

Flagsmith lets you manage features flags and remote config across web, mobile and server side applications. Deliver true Continuous Integration. Get builds out faster. Control who has access to new features.

548 lines (469 loc) 18.6 kB
import Flagsmith from '../../sdk/index.js'; import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js'; import { environmentJSON, environmentModel, flagsmith, fetch, offlineEnvironmentJSON, badFetch } from './utils.js'; import { DefaultFlag, Flags } from '../../sdk/models.js'; import { delay, getUserAgent } from '../../sdk/utils.js'; import { EnvironmentModel } from '../../flagsmith-engine/environments/models.js'; import { BaseOfflineHandler } from '../../sdk/offline_handlers.js'; import { Agent } from 'undici'; const isEsmBuild = process.env.ESM_BUILD === 'true'; vi.mock('../../sdk/polling_manager'); // Skip in ESM build: vi.mock doesn't work with external modules test.skipIf(isEsmBuild)('test_flagsmith_starts_polling_manager_on_init_if_enabled', () => { new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true }); expect(EnvironmentDataPollingManager).toBeCalled(); }); test('test_flagsmith_local_evaluation_key_required', () => { expect(() => { new Flagsmith({ environmentKey: 'bad.key', enableLocalEvaluation: true }); }).toThrow('Using local evaluation requires a server-side environment key'); }); // Skip in ESM build: instanceof fails across module boundaries test.skipIf(isEsmBuild)('test_update_environment_sets_environment', async () => { const flg = flagsmith({ environmentKey: 'ser.key' }); const model = environmentModel(JSON.parse(environmentJSON)); expect(await flg.getEnvironment()).toStrictEqual(model); }); test('test_update_environment_handles_paginated_document', async () => { type EnvDocumentMockResponse = { responseHeader: string | null; page: any; }; const createMockFetch = (pages: EnvDocumentMockResponse[]) => { let callCount = 0; return vi.fn((url: string, options?: RequestInit) => { if (url.includes('/environment-document')) { const document = envDocumentMockResponse[callCount]; if (document) { callCount++; const responseHeaders: Record<string, string> = {}; if (document.responseHeader) { responseHeaders['Link'] = `<${document.responseHeader}>; rel="next"`; } return Promise.resolve( new Response(JSON.stringify(document.page), { status: 200, headers: responseHeaders }) ); } } return Promise.resolve(new Response('unknown url ' + url, { status: 404 })); }); }; const envDocumentMockResponse: EnvDocumentMockResponse[] = [ { responseHeader: '/api/v1/environment-document?page=2', page: { id: 1, api_key: 'test-key', project: { id: 1, name: 'test', organisation: { id: 1, name: 'Test Org', feature_analytics: false, persist_trait_data: true, stop_serving_flags: false }, hide_disabled_flags: false, segments: [] }, feature_states: [ { feature_state_value: 'first_page_feature_state', multivariate_feature_state_values: [], django_id: 81027, feature: { id: 15058, type: 'STANDARD', name: 'string_feature' }, enabled: false }, { feature_state_value: 'second_page_feature_state', multivariate_feature_state_values: [], django_id: 81027, feature: { id: 15058, type: 'STANDARD', name: 'string_feature' }, enabled: false }, { feature_state_value: 'third_page_feature_state', multivariate_feature_state_values: [], django_id: 81027, feature: { id: 15058, type: 'STANDARD', name: 'string_feature' }, enabled: false } ], identity_overrides: [{ id: 1, identifier: 'user1' }] } }, { responseHeader: '/api/v1/environment-document?page=3', page: { api_key: 'test-key', project: { id: 1, name: 'test', organisation: { id: 1, name: 'Test Org', feature_analytics: false, persist_trait_data: true, stop_serving_flags: false }, hide_disabled_flags: false, segments: [] }, feature_states: [], identity_overrides: [{ id: 2, identifier: 'user2' }] } }, { responseHeader: null, page: { api_key: 'test-key', project: { id: 1, name: 'test', organisation: { id: 1, name: 'Test Org', feature_analytics: false, persist_trait_data: true, stop_serving_flags: false }, hide_disabled_flags: false, segments: [] }, feature_states: [], identity_overrides: [{ id: 2, identifier: 'user3' }] } } ]; const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, fetch: createMockFetch(envDocumentMockResponse) }); const environment = await flg.getEnvironment(); expect(environment.identityOverrides).toHaveLength(3); expect(environment.identityOverrides[0].identifier).toBe('user1'); expect(environment.identityOverrides[1].identifier).toBe('user2'); expect(environment.identityOverrides[2].identifier).toBe('user3'); expect(environment.featureStates).toHaveLength(3); expect(environment.featureStates[0].getValue()).toBe('first_page_feature_state'); expect(environment.featureStates[1].getValue()).toBe('second_page_feature_state'); expect(environment.featureStates[2].getValue()).toBe('third_page_feature_state'); expect(environment.project.name).toBe('test'); expect(environment.project.organisation.name).toBe('Test Org'); expect(environment.project.organisation.id).toBe(1); }); test('test_set_agent_options', async () => { const agent = new Agent({}); fetch.mockImplementationOnce((url, options) => { //@ts-ignore I give up if (options.dispatcher !== agent) { throw new Error('Agent has not been set on retry fetch'); } return Promise.resolve(new Response(environmentJSON)); }); const flg = flagsmith({ agent }); await flg.updateEnvironment(); }); test('test_get_identity_segments', async () => { const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true }); const segments = await flg.getIdentitySegments('user', { age: 21 }); expect(segments[0].name).toEqual('regular_segment'); const segments2 = await flg.getIdentitySegments('user', { age: 41 }); expect(segments2.length).toEqual(0); }); test('test_get_identity_segments_empty_without_local_eval', async () => { const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: false }); const segments = await flg.getIdentitySegments('user', { age: 21 }); expect(segments.length).toBe(0); }); test('test_update_environment_uses_req_when_inited', async () => { const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true }); delay(400); expect(async () => { await flg.updateEnvironment(); }).not.toThrow(); }); test('test_isFeatureEnabled_environment', async () => { const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; const flg = new Flagsmith({ environmentKey: 'key', defaultFlagHandler: defaultFlagHandler, enableAnalytics: true }); const flags = await flg.getEnvironmentFlags(); const featureValue = flags.isFeatureEnabled('some_feature'); expect(featureValue).toBe(true); }); test('test_fetch_recovers_after_single_API_error', async () => { fetch.mockRejectedValueOnce('Error during fetching the API response'); const flg = flagsmith({ environmentKey: 'key' }); const flags = await flg.getEnvironmentFlags(); const flag = flags.getFlag('some_feature'); expect(flag.isDefault).toBe(false); expect(flag.enabled).toBe(true); expect(flag.value).toBe('some-value'); }); test.each([ [false, 'key'], [true, 'ser.key'] ])( 'default flag handler is used when API is unavailable (local evaluation = %s)', async (enableLocalEvaluation, environmentKey) => { const flg = flagsmith({ enableLocalEvaluation, environmentKey, defaultFlagHandler: () => new DefaultFlag('some-default-value', true), fetch: badFetch }); const flags = await flg.getEnvironmentFlags(); const flag = flags.getFlag('some_feature'); expect(flag.isDefault).toBe(true); expect(flag.enabled).toBe(true); expect(flag.value).toBe('some-default-value'); } ); test('default flag handler used when timeout occurs', async () => { fetch.mockImplementation(async (...args) => { const forever = new Promise(() => {}); await forever; throw new Error('waited forever'); }); const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = () => defaultFlag; const flg = flagsmith({ environmentKey: 'key', defaultFlagHandler: defaultFlagHandler, requestTimeoutSeconds: 0.0001 }); const flags = await flg.getEnvironmentFlags(); const flag = flags.getFlag('some_feature'); expect(flag.isDefault).toBe(true); expect(flag.enabled).toBe(defaultFlag.enabled); expect(flag.value).toBe(defaultFlag.value); }); test('request timeout uses default if not provided', async () => { const flg = new Flagsmith({ environmentKey: 'key' }); expect(flg.requestTimeoutMs).toBe(10000); }); test('test_throws_when_no_identityFlags_returned_due_to_error', async () => { const flg = flagsmith({ environmentKey: 'key', fetch: badFetch }); await expect(async () => await flg.getIdentityFlags('identifier')).rejects.toThrow(); }); test('test onEnvironmentChange is called when provided', async () => { const callback = vi.fn(); const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, onEnvironmentChange: callback }); fetch.mockRejectedValueOnce(new Error('API error')); await flg.updateEnvironment().catch(() => { // Expected rejection }); expect(callback).toBeCalled(); }); test('test onEnvironmentChange is called after error', async () => { const callback = vi.fn(); const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, onEnvironmentChange: callback, fetch: badFetch }); await flg.updateEnvironment(); expect(callback).toHaveBeenCalled(); }); test('getIdentityFlags throws error if identifier is empty string', async () => { const flg = flagsmith({ environmentKey: 'key' }); await expect(flg.getIdentityFlags('')).rejects.toThrow( '`identifier` argument is missing or invalid.' ); }); test('getIdentitySegments throws error if identifier is empty string', async () => { const flg = flagsmith({ environmentKey: 'key' }); await expect(flg.getIdentitySegments('')).rejects.toThrow( '`identifier` argument is missing or invalid.' ); }); test('offline_mode', async () => { // Given const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON)); class DummyOfflineHandler extends BaseOfflineHandler { getEnvironment(): EnvironmentModel { return environment; } } // When const flg = flagsmith({ offlineMode: true, offlineHandler: new DummyOfflineHandler() }); // Then // we can request the flags from the client successfully const environmentFlags: Flags = await flg.getEnvironmentFlags(); let flag = environmentFlags.getFlag('some_feature'); expect(flag.isDefault).toBe(false); expect(flag.enabled).toBe(true); expect(flag.value).toBe('offline-value'); const identityFlags: Flags = await flg.getIdentityFlags('identity'); flag = identityFlags.getFlag('some_feature'); expect(flag.isDefault).toBe(false); expect(flag.enabled).toBe(true); expect(flag.value).toBe('offline-value'); }); test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () => { // Given const environment: EnvironmentModel = environmentModel(JSON.parse(offlineEnvironmentJSON)); const api_url = 'http://some.flagsmith.com/api/v1/'; const mock_offline_handler = new BaseOfflineHandler(); vi.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment); const flg = flagsmith({ environmentKey: 'some-key', apiUrl: api_url, offlineHandler: mock_offline_handler, offlineMode: true }); vi.spyOn(flg, 'getEnvironmentFlags'); vi.spyOn(flg, 'getIdentityFlags'); flg.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags'; flg.identitiesUrl = 'http://some.flagsmith.com/api/v1/identities'; // Mock a 500 Internal Server Error response const errorResponse = new Response(null, { status: 500, statusText: 'Internal Server Error' }); fetch.mockResolvedValue(errorResponse); // When const environmentFlags: Flags = await flg.getEnvironmentFlags(); expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); const identityFlags: Flags = await flg.getIdentityFlags('identity', {}); // Then expect(flg.getEnvironmentFlags).toHaveBeenCalled(); expect(flg.getIdentityFlags).toHaveBeenCalled(); expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true); expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value'); expect(identityFlags.isFeatureEnabled('some_feature')).toBe(true); expect(identityFlags.getFeatureValue('some_feature')).toBe('offline-value'); }); test('cannot use offline mode without offline handler', () => { // When and Then expect(() => new Flagsmith({ offlineMode: true, offlineHandler: undefined })).toThrowError( 'ValueError: offlineHandler must be provided to use offline mode.' ); }); test('cannot use both default handler and offline handler', () => { // When and Then expect(() => flagsmith({ offlineHandler: new BaseOfflineHandler(), defaultFlagHandler: () => new DefaultFlag('foo', true) }) ).toThrowError('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); }); test('cannot create Flagsmith client in remote evaluation without API key', () => { // When and Then expect(() => new Flagsmith({ environmentKey: '' })).toThrowError( 'ValueError: environmentKey is required.' ); }); test('test_localEvaluation_true__identity_overrides_evaluated', async () => { const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true }); await flg.updateEnvironment(); const flags = await flg.getIdentityFlags('overridden-id'); expect(flags.getFeatureValue('some_feature')).toEqual('some-overridden-value'); }); test('getIdentityFlags succeeds if initial fetch failed then succeeded', async () => { const defaultFlagHandler = vi.fn(() => new DefaultFlag('mock-default-value', true)); fetch.mockRejectedValue(new Error('Initial API error')); const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, defaultFlagHandler }); const defaultFlags = await flg.getIdentityFlags('test-user'); expect(defaultFlags.isFeatureEnabled('mock-default-value')).toBe(true); expect(defaultFlagHandler).toHaveBeenCalled(); fetch.mockResolvedValue(new Response(environmentJSON)); await flg.getEnvironment(); const flags2 = await flg.getIdentityFlags('test-user'); expect(flags2.isFeatureEnabled('some_feature')).toBe(true); }); // Skip in ESM build: require() path resolution differs test.skipIf(isEsmBuild)('get_user_agent_extracts_version_from_package_json', async () => { const userAgent = getUserAgent(); const packageJson = require('../../package.json'); expect(userAgent).toBe(`flagsmith-nodejs-sdk/${packageJson.version}`); }); test('Flags.fromEvaluationResult throws error when metadata.id is missing', () => { const evaluationResult = { flags: { test_feature: { enabled: true, name: 'test_feature', value: 'test_value', reason: 'DEFAULT', metadata: {} } }, segments: [] }; expect(() => Flags.fromEvaluationResult(evaluationResult as any)).toThrow( 'FlagResult metadata.id is missing for feature "test_feature". ' + 'This indicates a bug in the SDK, please report it.' ); });