diff --git a/flagsmith-core.ts b/flagsmith-core.ts index d1a36c5..be5d826 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -17,13 +17,13 @@ import { } from './types'; // @ts-ignore import deepEqual from 'fast-deep-equal'; +import { EvaluationContext } from './evaluation-context'; +import angularFetch from './utils/angular-fetch'; import { AsyncStorageType } from './utils/async-storage'; +import { ensureTrailingSlash } from './utils/ensureTrailingSlash'; import getChanges from './utils/get-changes'; -import angularFetch from './utils/angular-fetch'; import setDynatraceValue from './utils/set-dynatrace-value'; -import { EvaluationContext } from './evaluation-context'; import { isTraitEvaluationContext, toEvaluationContext, toTraitEvaluationContextObject } from './utils/types'; -import { ensureTrailingSlash } from './utils/ensureTrailingSlash'; enum FlagSource { "NONE" = "NONE", @@ -113,14 +113,14 @@ const Flagsmith = class { const userTraits: Traits = {}; features = features || []; traits = traits || []; - features.forEach(feature => { + features?.forEach(feature => { flags[feature.feature.name.toLowerCase().replace(/ /g, '_')] = { id: feature.feature.id, enabled: feature.enabled, value: feature.feature_state_value }; }); - traits.forEach(trait => { + traits?.forEach(trait => { userTraits[trait.trait_key.toLowerCase().replace(/ /g, '_')] = { transient: trait.transient, value: trait.trait_value, @@ -221,10 +221,11 @@ const Flagsmith = class { this.getJSON(api + 'identities/?identifier=' + encodeURIComponent(evaluationContext.identity.identifier) + (evaluationContext.identity.transient ? '&transient=true' : '')), ]) .then((res) => { - this.evaluationContext.identity = {...this.evaluationContext.identity, traits: {}} - return handleResponse(res?.[0] as IFlagsmithResponse | null) - }).catch(({ message }) => { - const error = new Error(message) + this.evaluationContext.identity = { ...this.evaluationContext.identity, traits: {} }; + return handleResponse(res?.[0] as IFlagsmithResponse | null); + }) + .catch((err) => { + const error = new Error(err.message) return Promise.reject(error) }); } else { @@ -238,7 +239,7 @@ const Flagsmith = class { analyticsFlags = () => { const { api } = this; - if (!this.evaluationEvent || !this.evaluationContext.environment || !this.evaluationEvent[this.evaluationContext.environment.apiKey]) { + if (!this.evaluationEvent || !this.evaluationContext.environment || !this.evaluationEvent[this.evaluationContext.environment.apiKey] || !this.initialised) { return } @@ -289,6 +290,7 @@ const Flagsmith = class { withTraits?: ITraits|null= null cacheOptions = {ttl:0, skipAPI: false, loadStale: false, storageKey: undefined as string|undefined} async init(config: IInitConfig) { + console.log("init", config) const evaluationContext = toEvaluationContext(config.evaluationContext || this.evaluationContext); try { const { @@ -317,6 +319,11 @@ const Flagsmith = class { _triggerLoadingState, applicationMetadata, } = config; + + if (!environmentID) { + throw new Error('`environmentID` and `api` cannot be empty'); + } + evaluationContext.environment = environmentID ? {apiKey: environmentID} : evaluationContext.environment; if (!evaluationContext.environment || !evaluationContext.environment.apiKey) { throw new Error('Please provide `evaluationContext.environment` with non-empty `apiKey`'); @@ -439,105 +446,142 @@ const Flagsmith = class { if (cacheFlags) { if (AsyncStorage && this.canUseStorage) { const onRetrievedStorage = async (error: Error | null, res: string | null) => { - if (res) { - let flagsChanged = null - const traitsChanged = null - try { - const json = JSON.parse(res) as IState; - let cachePopulated = false; - let staleCachePopulated = false; - if (json && json.api === this.api && json.evaluationContext?.environment?.apiKey === this.evaluationContext.environment?.apiKey) { - let setState = true; - if (this.evaluationContext.identity && (json.evaluationContext?.identity?.identifier !== this.evaluationContext.identity.identifier)) { - this.log("Ignoring cache, identity has changed from " + json.evaluationContext?.identity?.identifier + " to " + this.evaluationContext.identity.identifier ) - setState = false; - } - if (this.cacheOptions.ttl) { - if (!json.ts || (new Date().valueOf() - json.ts > this.cacheOptions.ttl)) { - if (json.ts && !this.cacheOptions.loadStale) { - this.log("Ignoring cache, timestamp is too old ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") - setState = false; - } - else if (json.ts && this.cacheOptions.loadStale) { - this.log("Loading stale cache, timestamp ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") - staleCachePopulated = true; - setState = true; + try { + if (res) { + let flagsChanged = null + const traitsChanged = null + try { + const json = JSON.parse(res) as IState; + let cachePopulated = false; + let staleCachePopulated = false; + if (json && json.api === this.api && json.evaluationContext?.environment?.apiKey === this.evaluationContext.environment?.apiKey) { + let setState = true; + if (this.evaluationContext.identity && (json.evaluationContext?.identity?.identifier !== this.evaluationContext.identity.identifier)) { + this.log("Ignoring cache, identity has changed from " + json.evaluationContext?.identity?.identifier + " to " + this.evaluationContext.identity.identifier ) + setState = false; + } + if (this.cacheOptions.ttl) { + if (!json.ts || (new Date().valueOf() - json.ts > this.cacheOptions.ttl)) { + if (json.ts && !this.cacheOptions.loadStale) { + this.log("Ignoring cache, timestamp is too old ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") + setState = false; + } + else if (json.ts && this.cacheOptions.loadStale) { + this.log("Loading stale cache, timestamp ts:" + json.ts + " ttl: " + this.cacheOptions.ttl + " time elapsed since cache: " + (new Date().valueOf()-json.ts)+"ms") + staleCachePopulated = true; + setState = true; + } } } + if (setState) { + cachePopulated = true; + flagsChanged = getChanges(this.flags, json.flags) + this.setState({ + ...json, + evaluationContext: toEvaluationContext({ + ...json.evaluationContext, + identity: json.evaluationContext?.identity ? { + ...json.evaluationContext?.identity, + traits: { + // Traits passed in flagsmith.init will overwrite server values + ...traits || {}, + } + } : undefined, + }) + }); + this.log("Retrieved flags from cache", json); + } } - if (setState) { - cachePopulated = true; - flagsChanged = getChanges(this.flags, json.flags) - this.setState({ - ...json, - evaluationContext: toEvaluationContext({ - ...json.evaluationContext, - identity: json.evaluationContext?.identity ? { - ...json.evaluationContext?.identity, - traits: { - // Traits passed in flagsmith.init will overwrite server values - ...traits || {}, - } - } : undefined, + + if (cachePopulated) { // retrieved flags from local storage + // fetch the flags if the cache is stale, or if we're not skipping api on cache hits + const shouldFetchFlags = !preventFetch && (!this.cacheOptions.skipAPI || staleCachePopulated) + this._onChange(null, + { isFromServer: false, flagsChanged, traitsChanged }, + this._loadedState(null, FlagSource.CACHE, shouldFetchFlags) + ); + this.oldFlags = this.flags; + if (this.cacheOptions.skipAPI && cachePopulated && !staleCachePopulated) { + this.log("Skipping API, using cache") + } + if (shouldFetchFlags) { + // We want to resolve init since we have cached flags + this.getFlags().catch((error) => { + this.onError?.(error) + if (this.api !== defaultAPI) { + this.log('Error fetching initial cached flags', error) + throw new Error(`Error querying ${this.api} for flags`); + } }) - }); - this.log("Retrieved flags from cache", json); + } + } else { + if (!preventFetch) { + try { + await this.getFlags(); + } catch (e) { + this.log('Exception fetching flags', e); + throw new Error(`Error querying ${this.api} for flags`); + } + } } + } catch (e) { + this.log("Exception fetching cached logs", e); + throw e; } - - if (cachePopulated) { // retrieved flags from local storage - // fetch the flags if the cache is stale, or if we're not skipping api on cache hits - const shouldFetchFlags = !preventFetch && (!this.cacheOptions.skipAPI || staleCachePopulated) - this._onChange(null, - { isFromServer: false, flagsChanged, traitsChanged }, - this._loadedState(null, FlagSource.CACHE, shouldFetchFlags) - ); - this.oldFlags = this.flags; - if (this.cacheOptions.skipAPI && cachePopulated && !staleCachePopulated) { - this.log("Skipping API, using cache") - } - if (shouldFetchFlags) { - // We want to resolve init since we have cached flags - - this.getFlags().catch((error) => { - this.onError?.(error) - }) - } - } else { - if (!preventFetch) { + } else { + if (!preventFetch) { + try { await this.getFlags(); + } catch (e) { + this.log('Exception fetching flags', e); + if (this.api !== defaultAPI) { + this.log('Error fetching flags', e); + throw new Error(`Error querying ${this.api} for flags`); + } } - } - } catch (e) { - this.log("Exception fetching cached logs", e); - } - } else { - if (!preventFetch) { - await this.getFlags(); - } else { - if (defaultFlags) { - this._onChange(null, - { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, - this._loadedState(null, FlagSource.DEFAULT_FLAGS), - ); - } else if (this.flags) { // flags exist due to set state being called e.g. from nextJS serverState - this._onChange(null, - { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, - this._loadedState(null, FlagSource.DEFAULT_FLAGS), - ); } else { - throw new Error(WRONG_FLAGSMITH_CONFIG); + if (defaultFlags) { + this._onChange(null, + { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, + this._loadedState(null, FlagSource.DEFAULT_FLAGS), + ); + } else if (this.flags) { // flags exist due to set state being called e.g. from nextJS serverState + this._onChange(null, + { isFromServer: false, flagsChanged: getChanges({}, this.flags), traitsChanged: getChanges({}, this.evaluationContext.identity?.traits) }, + this._loadedState(null, FlagSource.DEFAULT_FLAGS), + ); + } else { + throw new Error(WRONG_FLAGSMITH_CONFIG); + } } } + } catch (e) { + this.log("onRetrievedStorage error", e); + throw e; } }; try { const res = AsyncStorage.getItemSync? AsyncStorage.getItemSync(this.getStorageKey()) : await AsyncStorage.getItem(this.getStorageKey()); - await onRetrievedStorage(null, res) - } catch (e) {} + try { + await onRetrievedStorage(null, res) + } catch (e) { + this.log("Error retrieving item from storage", e); + throw e; + } + } catch (e) { + if (!defaultFlags) { + this.log('Error getting item from storage', e); + throw e; + } + } } } else if (!preventFetch) { - await this.getFlags(); + try { + await this.getFlags(); + } catch (e) { + this.log('Error fetching flags', e); + throw new Error(`Error querying ${this.api} for flags`); + } } else { if (defaultFlags) { this._onChange(null, { isFromServer: false, flagsChanged: getChanges({}, defaultFlags), traitsChanged: getChanges({}, evaluationContext.identity?.traits) }, this._loadedState(null, FlagSource.DEFAULT_FLAGS)); @@ -553,7 +597,9 @@ const Flagsmith = class { } } } catch (error) { + console.log("yolo") this.log('Error during initialisation ', error); + this.initialised = false; const typedError = error instanceof Error ? error : new Error(`${error}`); this.onError?.(typedError); throw error; diff --git a/test/init.test.ts b/test/init.test.ts index 4b162c8..ec09ab2 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,5 +1,5 @@ import { waitFor } from '@testing-library/react'; -import {defaultState, FLAGSMITH_KEY, getFlagsmith, getStateToCheck, identityState} from './test-constants'; +import { defaultState, FLAGSMITH_KEY, getFlagsmith, getStateToCheck, identityState } from './test-constants'; import { promises as fs } from 'fs'; describe('Flagsmith.init', () => { @@ -90,25 +90,28 @@ describe('Flagsmith.init', () => { }); test('should reject initialize with identity no key', async () => { const onChange = jest.fn(); - const { flagsmith, initConfig } = getFlagsmith({ - onChange, - evaluationContext: { environment: { apiKey: '' } }, - }); + const { flagsmith, initConfig } = getFlagsmith( + { + onChange, + evaluationContext: { environment: { apiKey: '' } }, + }, + true, + ); await expect(flagsmith.init(initConfig)).rejects.toThrow(Error); }); test('should sanitise api url', async () => { const onChange = jest.fn(); - const { flagsmith,initConfig } = getFlagsmith({ - api:'https://edge.api.flagsmith.com/api/v1/', + const { flagsmith, initConfig } = getFlagsmith({ + api: 'https://edge.api.flagsmith.com/api/v1/', onChange, }); - await flagsmith.init(initConfig) + await flagsmith.init(initConfig); expect(flagsmith.getState().api).toBe('https://edge.api.flagsmith.com/api/v1/'); - const { flagsmith:flagsmith2 } = getFlagsmith({ - api:'https://edge.api.flagsmith.com/api/v1', + const { flagsmith: flagsmith2 } = getFlagsmith({ + api: 'https://edge.api.flagsmith.com/api/v1', onChange, }); - await flagsmith2.init(initConfig) + await flagsmith2.init(initConfig); expect(flagsmith2.getState().api).toBe('https://edge.api.flagsmith.com/api/v1/'); }); test('should reject initialize with identity bad key', async () => { @@ -278,7 +281,7 @@ describe('Flagsmith.init', () => { const onChange = jest.fn(); const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange, - applicationMetadata: { + applicationMetadata: { name: 'Test App', version: '1.2.3', }, @@ -295,13 +298,12 @@ describe('Flagsmith.init', () => { }), }), ); - }); test('should send app name headers when provided', async () => { const onChange = jest.fn(); const { flagsmith, initConfig, AsyncStorage, mockFetch } = getFlagsmith({ onChange, - applicationMetadata: { + applicationMetadata: { name: 'Test App', }, }); @@ -316,7 +318,6 @@ describe('Flagsmith.init', () => { }), }), ); - }); test('should not send app name and version headers when not provided', async () => { @@ -338,4 +339,34 @@ describe('Flagsmith.init', () => { ); }); + test('should throw an environmentID missing error on init', async () => { + const onChange = jest.fn(); + const { flagsmith, initConfig } = getFlagsmith( + { + onChange, + environmentID: '', + }, + true, + ); + + await expect(flagsmith.init(initConfig)).rejects.toThrow(Error); + }); + + test('should throw an error and call onError when API is not reachable with custom api URL', async () => { + const onError = jest.fn(); + const customApi = 'https://wrong-host.com'; + const { flagsmith, initConfig } = getFlagsmith({ + api: customApi, + cacheFlags: false, + fetch: async () => { + return Promise.resolve(new Error('Mocked fetch error')); + }, + onError, + }); + + const initPromise = flagsmith.init(initConfig); + await expect(initPromise).rejects.toThrow(new Error(`Error querying ${customApi}/ for flags`)); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error(`Error querying ${customApi}/ for flags`)); + }); }); diff --git a/test/test-constants.ts b/test/test-constants.ts index 9cbf08d..fb3bf35 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -5,11 +5,11 @@ import Mock = jest.Mock; import { promises as fs } from 'fs'; export const environmentID = 'QjgYur4LQTwe5HpvbvhpzK'; // Flagsmith Demo Projects -export const FLAGSMITH_KEY = 'FLAGSMITH_DB' + "_" + environmentID; +export const FLAGSMITH_KEY = 'FLAGSMITH_DB' + '_' + environmentID; export const defaultState = { api: 'https://edge.api.flagsmith.com/api/v1/', evaluationContext: { - environment: {apiKey: environmentID}, + environment: { apiKey: environmentID }, }, flags: { hero: { @@ -24,25 +24,25 @@ export const defaultState = { }, }; -export const testIdentity = 'test_identity' +export const testIdentity = 'test_identity'; export const identityState = { api: 'https://edge.api.flagsmith.com/api/v1/', identity: testIdentity, evaluationContext: { - environment: {apiKey: environmentID}, + environment: { apiKey: environmentID }, identity: { identifier: testIdentity, traits: { - string_trait: {value: 'Example'}, - number_trait: {value: 1}, - } - } + string_trait: { value: 'Example' }, + number_trait: { value: 1 }, + }, + }, }, flags: { hero: { id: 1804, enabled: true, - value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg' + value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg', }, font_size: { id: 6149, enabled: true, value: 16 }, json_value: { id: 80317, enabled: true, value: '{"title":"Hello World"}' }, @@ -53,10 +53,10 @@ export const identityState = { export const defaultStateAlt = { ...defaultState, flags: { - 'example': { - 'id': 1, - 'enabled': true, - 'value': 'a', + example: { + id: 1, + enabled: true, + value: 'a', }, }, }; @@ -72,18 +72,18 @@ export function getStateToCheck(_state: IState) { return state; } -export function getFlagsmith(config: Partial = {}) { +export function getFlagsmith(config: Partial = {}, forceNoKey = false) { const flagsmith = createFlagsmithInstance(); const AsyncStorage = new MockAsyncStorage(); const mockFetch = jest.fn(async (url, options) => { switch (url) { case 'https://edge.api.flagsmith.com/api/v1/flags/': - return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')} + return { status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8') }; case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + testIdentity: - return {status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8')} + return { status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8') }; } - throw new Error('Please mock the call to ' + url) + throw new Error('Please mock the call to ' + url); }); //@ts-ignore, we want to test storage even though flagsmith thinks there is none @@ -91,19 +91,24 @@ export function getFlagsmith(config: Partial = {}) { const initConfig: IInitConfig = { AsyncStorage, fetch: mockFetch, + environmentID: forceNoKey ? '' : environmentID, ...config, }; initConfig.evaluationContext = { - environment: {apiKey: environmentID}, + environment: { apiKey: environmentID }, ...config?.evaluationContext, - } + }; return { flagsmith, initConfig, mockFetch, AsyncStorage }; } -export const delay = (ms:number) => new Promise((resolve) => setTimeout(resolve, ms)); -export function getMockFetchWithValue(mockFn:Mock, resolvedValue:object, ms=0) { - mockFn.mockReturnValueOnce(delay(ms).then(()=>Promise.resolve({ - status:200, - text: () => Promise.resolve(JSON.stringify(resolvedValue)), // Mock json() to return the mock response - json: () => Promise.resolve(resolvedValue), // Mock json() to return the mock response - }))) +export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export function getMockFetchWithValue(mockFn: Mock, resolvedValue: object, ms = 0) { + mockFn.mockReturnValueOnce( + delay(ms).then(() => + Promise.resolve({ + status: 200, + text: () => Promise.resolve(JSON.stringify(resolvedValue)), // Mock json() to return the mock response + json: () => Promise.resolve(resolvedValue), // Mock json() to return the mock response + }), + ), + ); }