From f2969480ea517404183af18057a4aa3b9e26f2bb Mon Sep 17 00:00:00 2001 From: novakzaballa Date: Thu, 12 Oct 2023 23:50:44 -0400 Subject: [PATCH 1/3] feat: offline-mode --- sdk/index.ts | 119 +++++++++++++++++++++++++++------------- sdk/offline_handlers.ts | 22 ++++++++ sdk/types.ts | 3 + 3 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 sdk/offline_handlers.ts diff --git a/sdk/index.ts b/sdk/index.ts index f52b425..d21429d 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -1,4 +1,4 @@ -import { RequestInit } from "node-fetch"; +import { RequestInit } from 'node-fetch'; import { getEnvironmentFeatureStates, getIdentityFeatureStates } from '../flagsmith-engine'; import { EnvironmentModel } from '../flagsmith-engine/environments/models'; import { buildEnvironmentModel } from '../flagsmith-engine/environments/util'; @@ -6,6 +6,7 @@ import { IdentityModel } from '../flagsmith-engine/identities/models'; import { TraitModel } from '../flagsmith-engine/identities/traits/models'; import { AnalyticsProcessor } from './analytics'; +import { BaseOfflineHandler } from './offline_handlers'; import { FlagsmithAPIError, FlagsmithClientError } from './errors'; import { DefaultFlag, Flags } from './models'; @@ -14,7 +15,7 @@ import { generateIdentitiesData, retryFetch } from './utils'; import { SegmentModel } from '../flagsmith-engine/segments/models'; import { getIdentitySegments } from '../flagsmith-engine/segments/evaluators'; import { FlagsmithCache, FlagsmithConfig } from './types'; -import pino, { Logger } from "pino"; +import pino, { Logger } from 'pino'; export { AnalyticsProcessor } from './analytics'; export { FlagsmithAPIError, FlagsmithClientError } from './errors'; @@ -26,10 +27,9 @@ export { FlagsmithCache, FlagsmithConfig } from './types'; const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'; const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; - export class Flagsmith { - environmentKey?: string; - apiUrl: string = DEFAULT_API_URL; + environmentKey?: string = undefined; + apiUrl?: string = undefined; customHeaders?: { [key: string]: any }; agent: RequestInit['agent']; requestTimeoutMs?: number; @@ -46,6 +46,8 @@ export class Flagsmith { environmentDataPollingManager?: EnvironmentDataPollingManager; environment!: EnvironmentModel; + offlineMode: boolean = false; + offlineHandler?: BaseOfflineHandler = undefined; private cache?: FlagsmithCache; private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void; @@ -65,6 +67,7 @@ export class Flagsmith { * const featureEnabledForIdentity = identityFlags.isFeatureEnabled("foo") * * @param {string} data.environmentKey: The environment key obtained from Flagsmith interface + * Required unless offlineMode is True. @param {string} data.apiUrl: Override the URL of the Flagsmith API to communicate with @param data.customHeaders: Additional headers to add to requests made to the Flagsmith API @@ -78,16 +81,22 @@ export class Flagsmith { @param {boolean} data.enableAnalytics: if enabled, sends additional requests to the Flagsmith API to power flag analytics charts @param data.defaultFlagHandler: callable which will be used in the case where - flags cannot be retrieved from the API or a non existent feature is + flags cannot be retrieved from the API or a non-existent feature is requested @param data.logger: an instance of the pino Logger class to use for logging - */ + @param {boolean} data.offlineMode: sets the client into offline mode. Relies on offlineHandler for + evaluating flags. + @param {BaseOfflineHandler} data.offlineHandler: provide a handler for offline logic. Used to get environment + document from another source when in offlineMode. Works in place of + defaultFlagHandler if offlineMode is not set and using remote evaluation. + */ constructor(data: FlagsmithConfig) { this.agent = data.agent; this.environmentKey = data.environmentKey; this.apiUrl = data.apiUrl || this.apiUrl; this.customHeaders = data.customHeaders; - this.requestTimeoutMs = 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS); + this.requestTimeoutMs = + 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS); this.enableLocalEvaluation = data.enableLocalEvaluation; this.environmentRefreshIntervalSeconds = data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds; @@ -100,9 +109,24 @@ export class Flagsmith { this.environmentUrl = `${this.apiUrl}environment-document/`; this.onEnvironmentChange = data.onEnvironmentChange; this.logger = data.logger || pino(); + this.offlineMode = data.offlineMode || false; + this.offlineHandler = data.offlineHandler; + + // argument validation + if (this.offlineMode && !this.offlineHandler) { + throw new Error('offline_handler must be provided to use offline mode.'); + } else if (this.defaultFlagHandler && this.offlineHandler) { + throw new Error('Cannot use both default_flag_handler and offline_handler.'); + } + + if (this.offlineHandler) { + this.environment = this.offlineHandler.getEnvironment(); + } if (!!data.cache) { - const missingMethods: string[] = ['has', 'get', 'set'].filter(method => data.cache && !data.cache[method]); + const missingMethods: string[] = ['has', 'get', 'set'].filter( + method => data.cache && !data.cache[method] + ); if (missingMethods.length > 0) { throw new Error( @@ -114,28 +138,36 @@ export class Flagsmith { this.cache = data.cache; } - if (this.enableLocalEvaluation) { - if (!this.environmentKey.startsWith('ser.')) { - console.error( - 'In order to use local evaluation, please generate a server key in the environment settings page.' + if (!this.offlineMode) { + if (!this.environmentKey) { + throw new Error('environmentKey is required'); + } + const apiUrl = data.apiUrl || DEFAULT_API_URL; + this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; + + if (this.enableLocalEvaluation) { + if (!this.environmentKey.startsWith('ser.')) { + console.error( + 'In order to use local evaluation, please generate a server key in the environment settings page.' + ); + } + this.environmentDataPollingManager = new EnvironmentDataPollingManager( + this, + this.environmentRefreshIntervalSeconds ); + this.environmentDataPollingManager.start(); + this.updateEnvironment(); } - this.environmentDataPollingManager = new EnvironmentDataPollingManager( - this, - this.environmentRefreshIntervalSeconds - ); - this.environmentDataPollingManager.start(); - this.updateEnvironment(); - } - this.analyticsProcessor = data.enableAnalytics - ? new AnalyticsProcessor({ - environmentKey: this.environmentKey, - baseApiUrl: this.apiUrl, - requestTimeoutMs: this.requestTimeoutMs, - logger: this.logger - }) - : undefined; + this.analyticsProcessor = data.enableAnalytics + ? new AnalyticsProcessor({ + environmentKey: this.environmentKey, + baseApiUrl: this.apiUrl, + requestTimeoutMs: this.requestTimeoutMs, + logger: this.logger + }) + : undefined; + } } /** * Get all the default for flags for the current environment. @@ -143,15 +175,15 @@ export class Flagsmith { * @returns Flags object holding all the flags for the current environment. */ async getEnvironmentFlags(): Promise { - const cachedItem = !!this.cache && await this.cache.get(`flags`); + const cachedItem = !!this.cache && (await this.cache.get(`flags`)); if (!!cachedItem) { return cachedItem; } - if (this.enableLocalEvaluation) { + if ((this.enableLocalEvaluation || this.offlineMode) && this.environment) { return new Promise((resolve, reject) => this.environmentPromise!.then(() => { resolve(this.getEnvironmentFlagsFromDocument()); - }).catch((e) => reject(e)) + }).catch(e => reject(e)) ); } if (this.environment) { @@ -160,6 +192,7 @@ export class Flagsmith { return this.getEnvironmentFlagsFromApi(); } + /** * Get all the flags for the current environment for a given identity. Will also upsert all traits to the Flagsmith API for future evaluations. Providing a @@ -173,15 +206,15 @@ export class Flagsmith { */ async getIdentityFlags(identifier: string, traits?: { [key: string]: any }): Promise { if (!identifier) { - throw new Error("`identifier` argument is missing or invalid.") + throw new Error('`identifier` argument is missing or invalid.'); } - const cachedItem = !!this.cache && await this.cache.get(`flags-${identifier}`); + const cachedItem = !!this.cache && (await this.cache.get(`flags-${identifier}`)); if (!!cachedItem) { return cachedItem; } traits = traits || {}; - if (this.enableLocalEvaluation) { + if ((this.enableLocalEvaluation || this.offlineMode) && this.environment) { return new Promise((resolve, reject) => this.environmentPromise!.then(() => { resolve(this.getIdentityFlagsFromDocument(identifier, traits || {})); @@ -207,7 +240,7 @@ export class Flagsmith { traits?: { [key: string]: any } ): Promise { if (!identifier) { - throw new Error("`identifier` argument is missing or invalid.") + throw new Error('`identifier` argument is missing or invalid.'); } traits = traits || {}; @@ -224,7 +257,7 @@ export class Flagsmith { const segments = getIdentitySegments(this.environment, identityModel); return resolve(segments); - }).catch((e) => reject(e)); + }).catch(e => reject(e)); }); } console.error('This function is only permitted with local evaluation.'); @@ -286,7 +319,7 @@ export class Flagsmith { headers: headers }, this.retries, - this.requestTimeoutMs || undefined, + this.requestTimeoutMs || undefined ); if (data.status !== 200) { @@ -321,7 +354,10 @@ export class Flagsmith { return flags; } - private async getIdentityFlagsFromDocument(identifier: string, traits: { [key: string]: any }): Promise { + private async getIdentityFlagsFromDocument( + identifier: string, + traits: { [key: string]: any } + ): Promise { const identityModel = this.buildIdentityModel( identifier, Object.keys(traits).map(key => ({ @@ -361,6 +397,9 @@ export class Flagsmith { } return flags; } catch (e) { + if (this.offlineHandler) { + return this.getEnvironmentFlagsFromDocument(); + } if (this.defaultFlagHandler) { return new Flags({ flags: {}, @@ -387,6 +426,9 @@ export class Flagsmith { } return flags; } catch (e) { + if (this.offlineHandler) { + return this.getIdentityFlagsFromDocument(identifier, traits); + } if (this.defaultFlagHandler) { return new Flags({ flags: {}, @@ -405,4 +447,3 @@ export class Flagsmith { } export default Flagsmith; - diff --git a/sdk/offline_handlers.ts b/sdk/offline_handlers.ts new file mode 100644 index 0000000..a6bd5e3 --- /dev/null +++ b/sdk/offline_handlers.ts @@ -0,0 +1,22 @@ +import * as fs from 'fs'; +import { buildEnvironmentModel } from '../flagsmith-engine/environments/util'; +import { EnvironmentModel } from '../flagsmith-engine/environments/models'; + +export class BaseOfflineHandler { + getEnvironment() : EnvironmentModel { + throw new Error('Not implemented'); + } +} + +export class LocalFileHandler extends BaseOfflineHandler { + environment: EnvironmentModel; + constructor(environment_document_path: string) { + super(); + const environment_document = fs.readFileSync(environment_document_path, 'utf8'); + this.environment = buildEnvironmentModel(JSON.parse(environment_document)); + } + + getEnvironment(): EnvironmentModel { + return this.environment; + } +} diff --git a/sdk/types.ts b/sdk/types.ts index cb5ad71..bfddb8e 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -2,6 +2,7 @@ import { DefaultFlag, Flags } from "./models"; import { EnvironmentModel } from "../flagsmith-engine"; import { RequestInit } from "node-fetch"; import { Logger } from "pino"; +import { BaseOfflineHandler } from "./offline_handlers"; export interface FlagsmithCache { get(key: string): Promise | undefined; @@ -24,4 +25,6 @@ export interface FlagsmithConfig { cache?: FlagsmithCache, onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void, logger?: Logger + offlineMode?: boolean; + offlineHandler?: BaseOfflineHandler; } From fd396a4543f3c200224a338eca61666cf0d19752 Mon Sep 17 00:00:00 2001 From: novakzaballa Date: Mon, 16 Oct 2023 23:59:52 -0400 Subject: [PATCH 2/3] test: Implement unit tests --- sdk/index.ts | 44 +++++++--- sdk/types.ts | 2 +- tests/sdk/data/offline-environment.json | 93 ++++++++++++++++++++ tests/sdk/flagsmith.test.ts | 112 ++++++++++++++++++++++-- tests/sdk/offline-handlers.test.ts | 33 +++++++ tests/sdk/utils.ts | 4 +- 6 files changed, 267 insertions(+), 21 deletions(-) create mode 100644 tests/sdk/data/offline-environment.json create mode 100644 tests/sdk/offline-handlers.test.ts diff --git a/sdk/index.ts b/sdk/index.ts index d21429d..6846684 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -40,9 +40,9 @@ export class Flagsmith { defaultFlagHandler?: (featureName: string) => DefaultFlag; - environmentFlagsUrl: string; - identitiesUrl: string; - environmentUrl: string; + environmentFlagsUrl?: string; + identitiesUrl?: string; + environmentUrl?: string; environmentDataPollingManager?: EnvironmentDataPollingManager; environment!: EnvironmentModel; @@ -90,7 +90,11 @@ export class Flagsmith { document from another source when in offlineMode. Works in place of defaultFlagHandler if offlineMode is not set and using remote evaluation. */ - constructor(data: FlagsmithConfig) { + constructor(data: FlagsmithConfig = {}) { + // if (!data.offlineMode && !data.environmentKey) { + // throw new Error('ValueError: environmentKey is required.'); + // } + this.agent = data.agent; this.environmentKey = data.environmentKey; this.apiUrl = data.apiUrl || this.apiUrl; @@ -104,9 +108,6 @@ export class Flagsmith { this.enableAnalytics = data.enableAnalytics || false; this.defaultFlagHandler = data.defaultFlagHandler; - this.environmentFlagsUrl = `${this.apiUrl}flags/`; - this.identitiesUrl = `${this.apiUrl}identities/`; - this.environmentUrl = `${this.apiUrl}environment-document/`; this.onEnvironmentChange = data.onEnvironmentChange; this.logger = data.logger || pino(); this.offlineMode = data.offlineMode || false; @@ -114,9 +115,9 @@ export class Flagsmith { // argument validation if (this.offlineMode && !this.offlineHandler) { - throw new Error('offline_handler must be provided to use offline mode.'); + throw new Error('ValueError: offlineHandler must be provided to use offline mode.'); } else if (this.defaultFlagHandler && this.offlineHandler) { - throw new Error('Cannot use both default_flag_handler and offline_handler.'); + throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); } if (this.offlineHandler) { @@ -140,11 +141,15 @@ export class Flagsmith { if (!this.offlineMode) { if (!this.environmentKey) { - throw new Error('environmentKey is required'); + throw new Error('ValueError: environmentKey is required.'); } + const apiUrl = data.apiUrl || DEFAULT_API_URL; this.apiUrl = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`; - + this.environmentFlagsUrl = `${this.apiUrl}flags/`; + this.identitiesUrl = `${this.apiUrl}identities/`; + this.environmentUrl = `${this.apiUrl}environment-document/`; + if (this.enableLocalEvaluation) { if (!this.environmentKey.startsWith('ser.')) { console.error( @@ -179,7 +184,7 @@ export class Flagsmith { if (!!cachedItem) { return cachedItem; } - if ((this.enableLocalEvaluation || this.offlineMode) && this.environment) { + if (this.enableLocalEvaluation && !this.offlineMode) { return new Promise((resolve, reject) => this.environmentPromise!.then(() => { resolve(this.getEnvironmentFlagsFromDocument()); @@ -214,13 +219,17 @@ export class Flagsmith { return cachedItem; } traits = traits || {}; - if ((this.enableLocalEvaluation || this.offlineMode) && this.environment) { + if (this.enableLocalEvaluation) { return new Promise((resolve, reject) => this.environmentPromise!.then(() => { resolve(this.getIdentityFlagsFromDocument(identifier, traits || {})); }).catch(e => reject(e)) ); } + if (this.offlineMode) { + return this.getIdentityFlagsFromDocument(identifier, traits || {}); + } + return this.getIdentityFlagsFromApi(identifier, traits); } @@ -337,6 +346,9 @@ export class Flagsmith { private environmentPromise: Promise | undefined; private async getEnvironmentFromApi() { + if (!this.environmentUrl) { + throw new Error('`apiUrl` argument is missing or invalid.'); + } const environment_data = await this.getJSONResponse(this.environmentUrl, 'GET'); return buildEnvironmentModel(environment_data); } @@ -384,6 +396,9 @@ export class Flagsmith { } private async getEnvironmentFlagsFromApi() { + if (!this.environmentFlagsUrl) { + throw new Error('`apiUrl` argument is missing or invalid.'); + } try { const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET'); const flags = Flags.fromAPIFlags({ @@ -412,6 +427,9 @@ export class Flagsmith { } private async getIdentityFlagsFromApi(identifier: string, traits: { [key: string]: any }) { + if (!this.identitiesUrl) { + throw new Error('`apiUrl` argument is missing or invalid.'); + } try { const data = generateIdentitiesData(identifier, traits); const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data); diff --git a/sdk/types.ts b/sdk/types.ts index bfddb8e..e854917 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -12,7 +12,7 @@ export interface FlagsmithCache { } export interface FlagsmithConfig { - environmentKey: string; + environmentKey?: string; apiUrl?: string; agent?:RequestInit['agent']; customHeaders?: { [key: string]: any }; diff --git a/tests/sdk/data/offline-environment.json b/tests/sdk/data/offline-environment.json new file mode 100644 index 0000000..04c68eb --- /dev/null +++ b/tests/sdk/data/offline-environment.json @@ -0,0 +1,93 @@ +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [ + { + "name": "regular_segment", + "feature_states": [ + { + "feature_state_value": "segment_override", + "multivariate_feature_state_values": [], + "django_id": 81027, + "feature": { + "name": "some_feature", + "type": "STANDARD", + "id": 1 + }, + "enabled": false + } + ], + "id": 1, + "rules": [ + { + "type": "ALL", + "conditions": [], + "rules": [ + { + "type": "ANY", + "conditions": [ + { + "value": "40", + "property_": "age", + "operator": "LESS_THAN" + } + ], + "rules": [] + } + ] + } + ] + } + ] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "offline-value", + "id": 1, + "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", + "feature": { + "name": "some_feature", + "type": "STANDARD", + "id": 1 + }, + "feature_segment": null, + "enabled": true + }, + { + "multivariate_feature_state_values": [ + { + "percentage_allocation": 100, + "multivariate_feature_option": { + "value": "bar", + "id": 1 + }, + "mv_fs_value_uuid": "42d5cdf9-8ec9-4b8d-a3ca-fd43c64d5f05", + "id": 1 + } + ], + "feature_state_value": "foo", + "feature": { + "name": "mv_feature", + "type": "MULTIVARIATE", + "id": 2 + }, + "feature_segment": null, + "featurestate_uuid": "96fc3503-09d7-48f1-a83b-2dc903d5c08a", + "enabled": false + } + ] + } + \ No newline at end of file diff --git a/tests/sdk/flagsmith.test.ts b/tests/sdk/flagsmith.test.ts index 525a659..bd44ed3 100644 --- a/tests/sdk/flagsmith.test.ts +++ b/tests/sdk/flagsmith.test.ts @@ -2,11 +2,12 @@ import Flagsmith from '../../sdk'; import { EnvironmentDataPollingManager } from '../../sdk/polling_manager'; import fetch, {RequestInit} from 'node-fetch'; import { environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON } from './utils'; -import { DefaultFlag } from '../../sdk/models'; +import { DefaultFlag, Flags } from '../../sdk/models'; import {delay, retryFetch} from '../../sdk/utils'; import * as utils from '../../sdk/utils'; import { EnvironmentModel } from '../../flagsmith-engine/environments/models'; import https from 'https' +import { BaseOfflineHandler } from '../../sdk/offline_handlers'; jest.mock('node-fetch'); jest.mock('../../sdk/polling_manager'); @@ -202,7 +203,7 @@ test('request timeout uses default if not provided', async () => { expect(flg.requestTimeoutMs).toBe(10000); }) -test('test_throws_when_no_identity_flags_returned_due_to_error', async () => { +test('test_throws_when_no_identityFlags_returned_due_to_error', async () => { // @ts-ignore fetch.mockReturnValue(Promise.resolve(new Response('bad data'))); @@ -275,10 +276,111 @@ test('getIdentitySegments throws error if identifier is empty string', () => { }) -async function wipeFeatureStateUUIDs (enviromentModel: EnvironmentModel) { +test('offline_mode', async() => { + // Given + const environment: EnvironmentModel = environmentModel(JSON.parse(environmentJSON('offline-environment.json'))); + + class DummyOfflineHandler extends BaseOfflineHandler { + getEnvironment(): EnvironmentModel { + return environment; + } + } + + // When + const flagsmith = new Flagsmith({ offlineMode: true, offlineHandler: new DummyOfflineHandler() }); + + // Then + // we can request the flags from the client successfully + const environmentFlags: Flags = await flagsmith.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 flagsmith.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(environmentJSON('offline-environment.json'))); + const api_url = 'http://some.flagsmith.com/api/v1/'; + const mock_offline_handler = new BaseOfflineHandler() as jest.Mocked; + + jest.spyOn(mock_offline_handler, 'getEnvironment').mockReturnValue(environment); + + const flagsmith = new Flagsmith({ + environmentKey: 'some-key', + apiUrl: api_url, + offlineHandler: mock_offline_handler, + }); + + jest.spyOn(flagsmith, 'getEnvironmentFlags'); + jest.spyOn(flagsmith, 'getIdentityFlags'); + + + flagsmith.environmentFlagsUrl = 'http://some.flagsmith.com/api/v1/environment-flags'; + flagsmith.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', + }); + + // @ts-ignore + fetch.mockReturnValue(Promise.resolve(errorResponse)); + + // When + await flagsmith.getEnvironmentFlags(); + await flagsmith.getIdentityFlags('identity', {}); + + // Then + expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); + expect(flagsmith.getEnvironmentFlags).toHaveBeenCalled(); + expect(flagsmith.getIdentityFlags).toHaveBeenCalled(); + + const environmentFlags:Flags = await flagsmith.getEnvironmentFlags(); + const identityFlags:Flags = await flagsmith.getIdentityFlags('identity', {}); + + 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(() => new Flagsmith({ + offlineHandler: new BaseOfflineHandler(), + defaultFlagHandler: (flagName) => 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 + // @ts-ignore + expect(() => new Flagsmith()).toThrowError('ValueError: environmentKey is required.'); +}); + + +async function wipeFeatureStateUUIDs (environmentModel: EnvironmentModel) { // TODO: this has been pulled out of tests above as a helper function. // I'm not entirely sure why it's necessary, however, we should look to remove. - enviromentModel.featureStates.forEach(fs => { + environmentModel.featureStates.forEach(fs => { // @ts-ignore fs.featurestateUUID = undefined; fs.multivariateFeatureStateValues.forEach(mvfsv => { @@ -286,7 +388,7 @@ async function wipeFeatureStateUUIDs (enviromentModel: EnvironmentModel) { mvfsv.mvFsValueUuid = undefined; }) }); - enviromentModel.project.segments.forEach(s => { + environmentModel.project.segments.forEach(s => { s.featureStates.forEach(fs => { // @ts-ignore fs.featurestateUUID = undefined; diff --git a/tests/sdk/offline-handlers.test.ts b/tests/sdk/offline-handlers.test.ts new file mode 100644 index 0000000..3d36e7c --- /dev/null +++ b/tests/sdk/offline-handlers.test.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs'; +import { LocalFileHandler } from '../../sdk/offline_handlers'; +import { EnvironmentModel } from '../../flagsmith-engine'; + +const offlineEnvironment = require('./data/offline-environment.json'); + +jest.mock('fs') + +const offlineEnvironmentString = JSON.stringify(offlineEnvironment) + +test('local file handler', () => { + const environmentDocumentFilePath = '/some/path/environment.json'; + + // Mock the fs.readFileSync function to return environmentJson + + // @ts-ignore + const readFileSyncMock = jest.spyOn(fs, 'readFileSync'); + readFileSyncMock.mockImplementation(() => offlineEnvironmentString); + + // Given + const localFileHandler = new LocalFileHandler(environmentDocumentFilePath); + + // When + const environmentModel = localFileHandler.getEnvironment(); + + // Then + expect(environmentModel).toBeInstanceOf(EnvironmentModel); + expect(environmentModel.apiKey).toBe('B62qaMZNwfiqT76p38ggrQ'); + expect(readFileSyncMock).toHaveBeenCalledWith(environmentDocumentFilePath, 'utf8'); + + // Restore the original implementation of fs.readFileSync + readFileSyncMock.mockRestore(); +}); diff --git a/tests/sdk/utils.ts b/tests/sdk/utils.ts index b09856c..e8897f8 100644 --- a/tests/sdk/utils.ts +++ b/tests/sdk/utils.ts @@ -42,8 +42,8 @@ export function flagsmith(params = {}) { }); } -export function environmentJSON() { - return readFileSync(DATA_DIR + 'environment.json', 'utf-8'); +export function environmentJSON(environmentFilename: string = 'environment.json') { + return readFileSync(DATA_DIR + environmentFilename, 'utf-8'); } export function environmentModel(environmentJSON: any) { From b1fc193f9f094fea42259befd4ff83561d371697 Mon Sep 17 00:00:00 2001 From: novakzaballa Date: Fri, 20 Oct 2023 10:04:08 -0400 Subject: [PATCH 3/3] fix: Code optimization on tests --- tests/sdk/flagsmith.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/sdk/flagsmith.test.ts b/tests/sdk/flagsmith.test.ts index bd44ed3..a87391e 100644 --- a/tests/sdk/flagsmith.test.ts +++ b/tests/sdk/flagsmith.test.ts @@ -337,17 +337,14 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () fetch.mockReturnValue(Promise.resolve(errorResponse)); // When - await flagsmith.getEnvironmentFlags(); - await flagsmith.getIdentityFlags('identity', {}); + const environmentFlags:Flags = await flagsmith.getEnvironmentFlags(); + const identityFlags:Flags = await flagsmith.getIdentityFlags('identity', {}); // Then expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); expect(flagsmith.getEnvironmentFlags).toHaveBeenCalled(); expect(flagsmith.getIdentityFlags).toHaveBeenCalled(); - - const environmentFlags:Flags = await flagsmith.getEnvironmentFlags(); - const identityFlags:Flags = await flagsmith.getIdentityFlags('identity', {}); - + expect(environmentFlags.isFeatureEnabled('some_feature')).toBe(true); expect(environmentFlags.getFeatureValue('some_feature')).toBe('offline-value');