diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/package-lock.json b/package-lock.json index 8629f28..dff0eaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "flagsmith-nodejs", - "version": "5.1.1", + "version": "6.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flagsmith-nodejs", - "version": "5.1.1", + "version": "6.0.0", "license": "MIT", "dependencies": { "pino": "^8.8.0", diff --git a/package.json b/package.json index 54f8b77..4989659 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flagsmith-nodejs", - "version": "5.1.1", + "version": "6.0.0", "description": "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.", "main": "./build/cjs/index.js", "type": "module", diff --git a/sdk/analytics.ts b/sdk/analytics.ts index 518bd2a..259c3e9 100644 --- a/sdk/analytics.ts +++ b/sdk/analytics.ts @@ -1,6 +1,6 @@ import { pino, Logger } from 'pino'; import { Fetch } from "./types.js"; -import { Flags } from "./models.js"; +import { FlagsmithConfig } from "./types.js"; export const ANALYTICS_ENDPOINT = './analytics/flags/'; @@ -21,17 +21,17 @@ export interface AnalyticsProcessorOptions { logger?: Logger; /** Custom {@link fetch} implementation to use for API requests. **/ fetch?: Fetch - + /** @deprecated Use {@link analyticsUrl} instead. **/ baseApiUrl?: string; } /** * Tracks how often individual features are evaluated whenever {@link trackFeature} is called. - * + * * Analytics data is posted after {@link trackFeature} is called and at least {@link ANALYTICS_TIMER} seconds have * passed since the previous analytics API request was made (if any), or by calling {@link flush}. - * + * * Data will stay in memory indefinitely until it can be successfully posted to the API. * @see https://docs.flagsmith.com/advanced-use/flag-analytics. */ @@ -89,7 +89,7 @@ export class AnalyticsProcessor { /** * Track a single evaluation event for a feature. * - * This method is called whenever {@link Flags.isFeatureEnabled}, {@link Flags.getFeatureValue} or {@link Flags.getFlag} are called. + * @see FlagsmithConfig.enableAnalytics */ trackFeature(featureName: string) { this.analyticsData[featureName] = (this.analyticsData[featureName] || 0) + 1; diff --git a/sdk/index.ts b/sdk/index.ts index 60269d2..f8691c9 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -7,11 +7,11 @@ import { TraitModel } from '../flagsmith-engine/index.js'; import {ANALYTICS_ENDPOINT, AnalyticsProcessor} from './analytics.js'; import { BaseOfflineHandler } from './offline_handlers.js'; -import { FlagsmithAPIError, FlagsmithClientError } from './errors.js'; +import { FlagsmithAPIError } from './errors.js'; import { DefaultFlag, Flags } from './models.js'; import { EnvironmentDataPollingManager } from './polling_manager.js'; -import { generateIdentitiesData, retryFetch } from './utils.js'; +import { Deferred, generateIdentitiesData, retryFetch } from './utils.js'; import { SegmentModel } from '../flagsmith-engine/index.js'; import { getIdentitySegments } from '../flagsmith-engine/segments/evaluators.js'; import { Fetch, FlagsmithCache, FlagsmithConfig, FlagsmithTraitValue, ITraitConfig } from './types.js'; @@ -27,6 +27,30 @@ export { FlagsmithCache, FlagsmithConfig } from './types.js'; const DEFAULT_API_URL = 'https://edge.api.flagsmith.com/api/v1/'; const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; +/** + * A client for evaluating Flagsmith feature flags. + * + * Flags are evaluated remotely by the Flagsmith API over HTTP by default. + * To evaluate flags locally, create the client using {@link FlagsmithConfig.enableLocalEvaluation} and a server-side SDK key. + * + * @example + * import { Flagsmith, Flags, DefaultFlag } from 'flagsmith-nodejs' + * + * const flagsmith = new Flagsmith({ + * environmentKey: 'your_sdk_key', + * defaultFlagHandler: (flagKey: string) => { new DefaultFlag(...) }, + * }); + * + * // Fetch the current environment flags + * const environmentFlags: Flags = flagsmith.getEnvironmentFlags() + * const isFooEnabled: boolean = environmentFlags.isFeatureEnabled('foo') + * + * // Evaluate flags for any identity + * const identityFlags: Flags = flagsmith.getIdentityFlags('my_user_123', {'vip': true}) + * const bannerVariation: string = identityFlags.getFeatureValue('banner_flag') + * + * @see FlagsmithConfig +*/ export class Flagsmith { environmentKey?: string = undefined; apiUrl?: string = undefined; @@ -45,66 +69,35 @@ export class Flagsmith { environmentUrl?: string; environmentDataPollingManager?: EnvironmentDataPollingManager; - environment!: EnvironmentModel; + private environment?: EnvironmentModel; offlineMode: boolean = false; offlineHandler?: BaseOfflineHandler = undefined; identitiesWithOverridesByIdentifier?: Map; private cache?: FlagsmithCache; - private onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void; + private onEnvironmentChange: (error: Error | null, result?: EnvironmentModel) => void; private analyticsProcessor?: AnalyticsProcessor; private logger: Logger; private customFetch: Fetch; + private readonly requestRetryDelayMilliseconds: number; + /** - * A Flagsmith client. - * - * Provides an interface for interacting with the Flagsmith http API. - * Basic Usage:: - * - * import flagsmith from Flagsmith - * const flagsmith = new Flagsmith({environmentKey: ''}); - * const environmentFlags = flagsmith.getEnvironmentFlags(); - * const featureEnabled = environmentFlags.isFeatureEnabled('foo'); - * const identityFlags = flagsmith.getIdentityFlags('identifier', {'foo': 'bar'}); - * const featureEnabledForIdentity = identityFlags.isFeatureEnabled("foo") + * Creates a new {@link Flagsmith} client. * - * @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 - @param {number} data.requestTimeoutSeconds: Number of seconds to wait for a request to - complete before terminating the request - @param {boolean} data.enableLocalEvaluation: Enables local evaluation of flags - @param {number} data.environmentRefreshIntervalSeconds: If using local evaluation, - specify the interval period between refreshes of local environment data - @param {number} data.retries: a urllib3.Retry object to use on all http requests to the - Flagsmith API - @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 - 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 = {}) { - // if (!data.offlineMode && !data.environmentKey) { - // throw new Error('ValueError: environmentKey is required.'); - // } - + * If using local evaluation, the environment will be fetched lazily when needed by any method. Polling the + * environment for updates will start after {@link environmentRefreshIntervalSeconds} once the client is created. + * @param data The {@link FlagsmithConfig} options for this client. + */ + constructor(data: FlagsmithConfig) { this.agent = data.agent; this.customFetch = data.fetch ?? fetch; this.environmentKey = data.environmentKey; - this.apiUrl = data.apiUrl || this.apiUrl; + this.apiUrl = data.apiUrl || DEFAULT_API_URL; this.customHeaders = data.customHeaders; this.requestTimeoutMs = 1000 * (data.requestTimeoutSeconds ?? DEFAULT_REQUEST_TIMEOUT_SECONDS); + this.requestRetryDelayMilliseconds = data.requestRetryDelayMilliseconds ?? 1000; this.enableLocalEvaluation = data.enableLocalEvaluation; this.environmentRefreshIntervalSeconds = data.environmentRefreshIntervalSeconds || this.environmentRefreshIntervalSeconds; @@ -112,7 +105,7 @@ export class Flagsmith { this.enableAnalytics = data.enableAnalytics || false; this.defaultFlagHandler = data.defaultFlagHandler; - this.onEnvironmentChange = data.onEnvironmentChange; + this.onEnvironmentChange = (error, result) => data.onEnvironmentChange?.(error, result); this.logger = data.logger || pino(); this.offlineMode = data.offlineMode || false; this.offlineHandler = data.offlineHandler; @@ -124,10 +117,6 @@ export class Flagsmith { throw new Error('ValueError: Cannot use both defaultFlagHandler and offlineHandler.'); } - if (this.offlineHandler) { - this.environment = this.offlineHandler.getEnvironment(); - } - if (!!data.cache) { this.cache = data.cache; } @@ -146,16 +135,16 @@ export class Flagsmith { 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.' + throw new Error('Using local evaluation requires a server-side environment key'); + } + if (this.environmentRefreshIntervalSeconds > 0){ + this.environmentDataPollingManager = new EnvironmentDataPollingManager( + this, + this.environmentRefreshIntervalSeconds, + this.logger, ); + this.environmentDataPollingManager.start(); } - this.environmentDataPollingManager = new EnvironmentDataPollingManager( - this, - this.environmentRefreshIntervalSeconds - ); - this.environmentDataPollingManager.start(); - this.updateEnvironment(); } if (data.enableAnalytics) { @@ -178,18 +167,21 @@ export class Flagsmith { if (!!cachedItem) { return cachedItem; } - if (this.enableLocalEvaluation && !this.offlineMode) { - return new Promise((resolve, reject) => - this.environmentPromise!.then(() => { - resolve(this.getEnvironmentFlagsFromDocument()); - }).catch(e => reject(e)) - ); - } - if (this.environment) { - return this.getEnvironmentFlagsFromDocument(); + try { + if (this.enableLocalEvaluation || this.offlineMode) { + return await this.getEnvironmentFlagsFromDocument(); + } + return await this.getEnvironmentFlagsFromApi(); + } catch (error) { + if (!this.defaultFlagHandler) { + throw new Error('getEnvironmentFlags failed and no default flag handler was provided', { cause: error }); + } + this.logger.error(error, 'getEnvironmentFlags failed'); + return new Flags({ + flags: {}, + defaultFlagHandler: this.defaultFlagHandler + }); } - - return this.getEnvironmentFlagsFromApi(); } /** @@ -217,18 +209,21 @@ export class Flagsmith { return cachedItem; } traits = traits || {}; - 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 || {}); + try { + if (this.enableLocalEvaluation || this.offlineMode) { + return await this.getIdentityFlagsFromDocument(identifier, traits || {}); + } + return await this.getIdentityFlagsFromApi(identifier, traits, transient); + } catch (error) { + if (!this.defaultFlagHandler) { + throw new Error('getIdentityFlags failed and no default flag handler was provided', { cause: error }) + } + this.logger.error(error, 'getIdentityFlags failed'); + return new Flags({ + flags: {}, + defaultFlagHandler: this.defaultFlagHandler + }); } - - return this.getIdentityFlagsFromApi(identifier, traits, transient); } /** @@ -242,66 +237,69 @@ export class Flagsmith { Flagsmith, e.g. {"num_orders": 10} * @returns Segments that the given identity belongs to. */ - getIdentitySegments( + async getIdentitySegments( identifier: string, traits?: { [key: string]: any } ): Promise { if (!identifier) { throw new Error('`identifier` argument is missing or invalid.'); } + if (!this.enableLocalEvaluation) { + this.logger.error('This function is only permitted with local evaluation.'); + return Promise.resolve([]); + } traits = traits || {}; - if (this.enableLocalEvaluation) { - return new Promise((resolve, reject) => { - return this.environmentPromise!.then(() => { - const identityModel = this.getIdentityModel( - identifier, - Object.keys(traits || {}).map(key => ({ - key, - value: traits?.[key] - })) - ); + const environment = await this.getEnvironment(); + const identityModel = this.getIdentityModel( + environment, + identifier, + Object.keys(traits || {}).map(key => ({ + key, + value: traits?.[key] + })) + ); - const segments = getIdentitySegments(this.environment, identityModel); - return resolve(segments); - }).catch(e => reject(e)); - }); - } - console.error('This function is only permitted with local evaluation.'); - return Promise.resolve([]); + return getIdentitySegments(environment, identityModel); } - /** - * Updates the environment state for local flag evaluation. - * Sets a local promise to prevent race conditions in getIdentityFlags / getIdentitySegments. - * You only need to call this if you wish to bypass environmentRefreshIntervalSeconds. - */ - async updateEnvironment() { + private async fetchEnvironment(): Promise { + const deferred = new Deferred(); + this.environmentPromise = deferred.promise; try { - const request = this.getEnvironmentFromApi(); - if (!this.environmentPromise) { - this.environmentPromise = request.then(res => { - this.environment = res; - }); - await this.environmentPromise; - } else { - this.environment = await request; - } - if (this.environment.identityOverrides?.length) { + const environment = await this.getEnvironmentFromApi(); + this.environment = environment; + if (environment.identityOverrides?.length) { this.identitiesWithOverridesByIdentifier = new Map( - this.environment.identityOverrides.map(identity => [ - identity.identifier, - identity - ]) + environment.identityOverrides.map(identity => [identity.identifier, identity]) ); } - if (this.onEnvironmentChange) { - this.onEnvironmentChange(null, this.environment); + deferred.resolve(environment); + return deferred.promise; + } catch (error) { + deferred.reject(error); + return deferred.promise; + } finally { + this.environmentPromise = undefined; + } + } + + /** + * Fetch the latest environment state from the Flagsmith API to use for local flag evaluation. + * + * If the environment is currently being fetched, calling this method will not cause additional fetches. + */ + async updateEnvironment(): Promise { + try { + if (this.environmentPromise) { + await this.environmentPromise + return } + const environment = await this.fetchEnvironment(); + this.onEnvironmentChange(null, environment); } catch (e) { - if (this.onEnvironmentChange) { - this.onEnvironmentChange(e as Error, this.environment); - } + this.logger.error(e, 'updateEnvironment failed'); + this.onEnvironmentChange(e as Error); } } @@ -335,6 +333,7 @@ export class Flagsmith { }, this.retries, this.requestTimeoutMs, + this.requestRetryDelayMilliseconds, this.customFetch, ); @@ -350,7 +349,25 @@ export class Flagsmith { /** * This promise ensures that the environment is retrieved before attempting to locally evaluate. */ - private environmentPromise: Promise | undefined; + private environmentPromise?: Promise; + + /** + * Returns the current environment, fetching it from the API if needed. + * + * Calling this method concurrently while the environment is being fetched will not cause additional requests. + */ + async getEnvironment(): Promise { + if (this.offlineHandler) { + return this.offlineHandler.getEnvironment(); + } + if (this.environment) { + return this.environment; + } + if (!this.environmentPromise) { + this.environmentPromise = this.fetchEnvironment(); + } + return this.environmentPromise; + } private async getEnvironmentFromApi() { if (!this.environmentUrl) { @@ -361,8 +378,9 @@ export class Flagsmith { } private async getEnvironmentFlagsFromDocument(): Promise { + const environment = await this.getEnvironment(); const flags = Flags.fromFeatureStateModels({ - featureStates: getEnvironmentFeatureStates(this.environment), + featureStates: getEnvironmentFeatureStates(environment), analyticsProcessor: this.analyticsProcessor, defaultFlagHandler: this.defaultFlagHandler }); @@ -376,7 +394,9 @@ export class Flagsmith { identifier: string, traits: { [key: string]: any } ): Promise { + const environment = await this.getEnvironment(); const identityModel = this.getIdentityModel( + environment, identifier, Object.keys(traits).map(key => ({ key, @@ -384,7 +404,7 @@ export class Flagsmith { })) ); - const featureStates = getIdentityFeatureStates(this.environment, identityModel); + const featureStates = getIdentityFeatureStates(environment, identityModel); const flags = Flags.fromFeatureStateModels({ featureStates: featureStates, @@ -404,30 +424,16 @@ export class Flagsmith { 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({ - apiFlags: apiFlags, - analyticsProcessor: this.analyticsProcessor, - defaultFlagHandler: this.defaultFlagHandler - }); - if (!!this.cache) { - await this.cache.set('flags', flags); - } - return flags; - } catch (e) { - if (this.offlineHandler) { - return this.getEnvironmentFlagsFromDocument(); - } - if (this.defaultFlagHandler) { - return new Flags({ - flags: {}, - defaultFlagHandler: this.defaultFlagHandler - }); - } - - throw e; + const apiFlags = await this.getJSONResponse(this.environmentFlagsUrl, 'GET'); + const flags = Flags.fromAPIFlags({ + apiFlags: apiFlags, + analyticsProcessor: this.analyticsProcessor, + defaultFlagHandler: this.defaultFlagHandler + }); + if (!!this.cache) { + await this.cache.set('flags', flags); } + return flags; } private async getIdentityFlagsFromApi( @@ -438,41 +444,32 @@ export class Flagsmith { if (!this.identitiesUrl) { throw new Error('`apiUrl` argument is missing or invalid.'); } - try { - const data = generateIdentitiesData(identifier, traits, transient); - const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data); - const flags = Flags.fromAPIFlags({ - apiFlags: jsonResponse['flags'], - analyticsProcessor: this.analyticsProcessor, - defaultFlagHandler: this.defaultFlagHandler - }); - if (!!this.cache) { - await this.cache.set(`flags-${identifier}`, flags); - } - return flags; - } catch (e) { - if (this.offlineHandler) { - return this.getIdentityFlagsFromDocument(identifier, traits); - } - if (this.defaultFlagHandler) { - return new Flags({ - flags: {}, - defaultFlagHandler: this.defaultFlagHandler - }); - } - - throw e; + const data = generateIdentitiesData(identifier, traits, transient); + const jsonResponse = await this.getJSONResponse(this.identitiesUrl, 'POST', data); + const flags = Flags.fromAPIFlags({ + apiFlags: jsonResponse['flags'], + analyticsProcessor: this.analyticsProcessor, + defaultFlagHandler: this.defaultFlagHandler + }); + if (!!this.cache) { + await this.cache.set(`flags-${identifier}`, flags); } + return flags; } - private getIdentityModel(identifier: string, traits: { key: string; value: any }[]) { + private getIdentityModel( + environment: EnvironmentModel, + identifier: string, + traits: { key: string; value: any }[] + ) { const traitModels = traits.map(trait => new TraitModel(trait.key, trait.value)); - let identityWithOverrides = this.identitiesWithOverridesByIdentifier?.get(identifier); + let identityWithOverrides = + this.identitiesWithOverridesByIdentifier?.get(identifier); if (identityWithOverrides) { identityWithOverrides.updateTraits(traitModels); return identityWithOverrides; } - return new IdentityModel('0', traitModels, [], this.environment.apiKey, identifier); + return new IdentityModel('0', traitModels, [], environment.apiKey, identifier); } } diff --git a/sdk/polling_manager.ts b/sdk/polling_manager.ts index 414a191..d01e7de 100644 --- a/sdk/polling_manager.ts +++ b/sdk/polling_manager.ts @@ -1,20 +1,27 @@ import Flagsmith from './index.js'; +import { Logger } from 'pino'; export class EnvironmentDataPollingManager { private interval?: NodeJS.Timeout; private main: Flagsmith; private refreshIntervalSeconds: number; + private logger: Logger; - constructor(main: Flagsmith, refreshIntervalSeconds: number) { + constructor(main: Flagsmith, refreshIntervalSeconds: number, logger: Logger) { this.main = main; this.refreshIntervalSeconds = refreshIntervalSeconds; + this.logger = logger; } start() { const updateEnvironment = () => { if (this.interval) clearInterval(this.interval); this.interval = setInterval(async () => { - await this.main.updateEnvironment(); + try { + await this.main.updateEnvironment(); + } catch (error) { + this.logger.error(error, 'failed to poll environment'); + } }, this.refreshIntervalSeconds * 1000); }; updateEnvironment(); diff --git a/sdk/types.ts b/sdk/types.ts index 1e99c4e..6762d64 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -3,6 +3,7 @@ import { EnvironmentModel } from '../flagsmith-engine/index.js'; import { Dispatcher } from 'undici-types'; import { Logger } from 'pino'; import { BaseOfflineHandler } from './offline_handlers.js'; +import { Flagsmith } from './index.js' export type IFlagsmithValue = T; @@ -27,22 +28,92 @@ export interface FlagsmithCache { export type Fetch = typeof fetch +/** + * The configuration options for a {@link Flagsmith} client. + */ export interface FlagsmithConfig { + /** + * The environment's client-side or server-side key. + */ environmentKey?: string; + /** + * The Flagsmith API URL. Set this if you are not using Flagsmith's public service, i.e. https://app.flagsmith.com. + * + * @default https://edge.api.flagsmith.com/api/v1/ + */ apiUrl?: string; + /** + * A custom {@link Dispatcher} to use when making HTTP requests. + */ agent?: Dispatcher; + /** + * A custom {@link fetch} implementation to use when making HTTP requests. + */ fetch?: Fetch; - customHeaders?: { [key: string]: any }; + /** + * Custom headers to use in all HTTP requests. + */ + customHeaders?: HeadersInit + /** + * The network request timeout duration, in seconds. + * + * @default 10 + */ requestTimeoutSeconds?: number; + /** + * The amount of time, in milliseconds, to wait before retrying failed network requests. + */ + requestRetryDelayMilliseconds?: number; + /** + * If enabled, flags are evaluated locally using the environment state cached in memory. + * + * The client will lazily fetch the environment from the Flagsmith API, and poll it every {@link environmentRefreshIntervalSeconds}. + */ enableLocalEvaluation?: boolean; + /** + * The time, in seconds, to wait before refreshing the cached environment state. + * @default 60 + */ environmentRefreshIntervalSeconds?: number; + /** + * How many times to retry any failed network request before giving up. + * @default 3 + */ retries?: number; + /** + * If enabled, the client will keep track of any flags evaluated using {@link Flags.isFeatureEnabled}, + * {@link Flags.getFeatureValue} or {@link Flags.getFlag}, and periodically flush this data to the Flagsmith API. + */ enableAnalytics?: boolean; - defaultFlagHandler?: (featureName: string) => DefaultFlag; + /** + * Used to return fallback values for flags when evaluation fails for any reason. If not provided and flag + * evaluation fails, an error will be thrown intsead. + * + * @param flagKey The key of the flag that failed to evaluate. + * + * @example + * // All flags disabled and with no value by default + * const defaultHandler = () => new DefaultFlag(undefined, false) + * + * // Enable only VIP flags by default + * const vipDefaultHandler = (key: string) => new Default(undefined, key.startsWith('vip_')) + */ + defaultFlagHandler?: (flagKey: string) => DefaultFlag; cache?: FlagsmithCache; - onEnvironmentChange?: (error: Error | null, result: EnvironmentModel) => void; + /** + * A callback function to invoke whenever the cached environment is updated. + * @param error The error that occurred when the environment state failed to update, if any. + * @param result The updated environment state, if no error was thrown. + */ + onEnvironmentChange?: (error: Error | null, result?: EnvironmentModel) => void; logger?: Logger; + /** + * If enabled, the client will work offline and not make any network requests. Requires {@link offlineHandler}. + */ offlineMode?: boolean; + /** + * If {@link offlineMode} is enabled, this handler is used to calculate the values of all flags. + */ offlineHandler?: BaseOfflineHandler; } diff --git a/sdk/utils.ts b/sdk/utils.ts index 5a4f262..612aef6 100644 --- a/sdk/utils.ts +++ b/sdk/utils.ts @@ -46,25 +46,59 @@ export const retryFetch = ( fetchOptions: RequestInit & { dispatcher?: Dispatcher }, retries: number = 3, timeoutMs: number = 10, // set an overall timeout for this function + retryDelayMs: number = 1000, customFetch: Fetch, ): Promise => { - return new Promise((resolve, reject) => { - const retryWrapper = (n: number) => { - customFetch(url, { + const retryWrapper = async (n: number): Promise => { + try { + return await customFetch(url, { ...fetchOptions, signal: AbortSignal.timeout(timeoutMs) - }) - .then(res => resolve(res)) - .catch(async err => { - if (n > 0) { - await delay(1000); - retryWrapper(--n); - } else { - reject(err); - } }); - }; - - retryWrapper(retries); - }); + } catch (e) { + if (n > 0) { + await delay(retryDelayMs); + return await retryWrapper(n - 1); + } else { + throw e; + } + } + }; + return retryWrapper(retries); }; + +/** + * A deferred promise can be resolved or rejected outside its creation scope. + * + * @template T The type of the value that the deferred promise will resolve to. + * + * @example + * const deferred = new Deferred() + * + * // Pass the promise somewhere + * performAsyncOperation(deferred.promise) + * + * // Resolve it when ready from anywhere + * deferred.resolve("Operation completed") + * deferred.failed("Error") + */ +export class Deferred { + public readonly promise: Promise; + private resolvePromise!: (value: T | PromiseLike) => void; + private rejectPromise!: (reason?: unknown) => void; + + constructor(initial?: T) { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + public resolve(value: T | PromiseLike): void { + this.resolvePromise(value); + } + + public reject(reason?: unknown): void { + this.rejectPromise(reason); + } +} diff --git a/tests/sdk/analytics.test.ts b/tests/sdk/analytics.test.ts index fa687dc..da9ea4d 100644 --- a/tests/sdk/analytics.test.ts +++ b/tests/sdk/analytics.test.ts @@ -1,9 +1,5 @@ import {analyticsProcessor, fetch} from './utils.js'; -afterEach(() => { - vi.resetAllMocks(); -}); - test('test_analytics_processor_track_feature_updates_analytics_data', () => { const aP = analyticsProcessor(); aP.trackFeature("myFeature"); diff --git a/tests/sdk/flagsmith-cache.test.ts b/tests/sdk/flagsmith-cache.test.ts index f18df25..77fd92e 100644 --- a/tests/sdk/flagsmith-cache.test.ts +++ b/tests/sdk/flagsmith-cache.test.ts @@ -1,12 +1,6 @@ import { fetch, environmentJSON, environmentModel, flagsJSON, flagsmith, identitiesJSON, TestCache } from './utils.js'; -beforeEach(() => { - vi.clearAllMocks(); -}); - test('test_empty_cache_not_read_but_populated', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const cache = new TestCache(); const set = vi.spyOn(cache, 'set'); @@ -23,8 +17,6 @@ test('test_empty_cache_not_read_but_populated', async () => { }); test('test_api_not_called_when_cache_present', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const cache = new TestCache(); const set = vi.spyOn(cache, 'set'); @@ -56,27 +48,25 @@ test('test_api_called_twice_when_no_cache', async () => { }); test('test_get_environment_flags_uses_local_environment_when_available', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const cache = new TestCache(); const set = vi.spyOn(cache, 'set'); - const flg = flagsmith({ cache }); + const flg = flagsmith({ cache, environmentKey: 'ser.key', enableLocalEvaluation: true }); const model = environmentModel(JSON.parse(environmentJSON)); - flg.environment = model; + const getEnvironment = vi.spyOn(flg, 'getEnvironment') + getEnvironment.mockResolvedValue(model) - const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); + const allFlags = (await flg.getEnvironmentFlags()).allFlags(); expect(set).toBeCalled(); expect(fetch).toBeCalledTimes(0); + expect(getEnvironment).toBeCalledTimes(1); expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled); expect(allFlags[0].value).toBe(model.featureStates[0].getValue()); expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name); }); test('test_cache_used_for_identity_flags', async () => { - fetch.mockResolvedValue(new Response(identitiesJSON)); - const cache = new TestCache(); const set = vi.spyOn(cache, 'set'); @@ -98,8 +88,6 @@ test('test_cache_used_for_identity_flags', async () => { }); test('test_cache_used_for_identity_flags_local_evaluation', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); - const cache = new TestCache(); const set = vi.spyOn(cache, 'set'); diff --git a/tests/sdk/flagsmith-environment-flags.test.ts b/tests/sdk/flagsmith-environment-flags.test.ts index e6356f2..b08ad61 100644 --- a/tests/sdk/flagsmith-environment-flags.test.ts +++ b/tests/sdk/flagsmith-environment-flags.test.ts @@ -4,13 +4,7 @@ import { DefaultFlag } from '../../sdk/models.js'; vi.mock('../../sdk/polling_manager'); -beforeEach(() => { - vi.clearAllMocks(); -}); - test('test_get_environment_flags_calls_api_when_no_local_environment', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const flg = flagsmith(); const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); @@ -20,20 +14,6 @@ test('test_get_environment_flags_calls_api_when_no_local_environment', async () expect(allFlags[0].featureName).toBe('some_feature'); }); -test('test_get_environment_flags_uses_local_environment_when_available', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - - const flg = flagsmith(); - const model = environmentModel(JSON.parse(environmentJSON)); - flg.environment = model; - - const allFlags = await (await flg.getEnvironmentFlags()).allFlags(); - expect(fetch).toBeCalledTimes(0); - expect(allFlags[0].enabled).toBe(model.featureStates[0].enabled); - expect(allFlags[0].value).toBe(model.featureStates[0].getValue()); - expect(allFlags[0].featureName).toBe(model.featureStates[0].feature.name); -}); - test('test_default_flag_is_used_when_no_environment_flags_returned', async () => { fetch.mockResolvedValue(new Response(JSON.stringify([]))); @@ -57,8 +37,6 @@ test('test_default_flag_is_used_when_no_environment_flags_returned', async () => }); test('test_analytics_processor_tracks_flags', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; @@ -78,8 +56,6 @@ test('test_analytics_processor_tracks_flags', async () => { }); test('test_getFeatureValue', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; @@ -106,7 +82,7 @@ test('test_throws_when_no_default_flag_handler_after_multiple_API_errors', async await expect(async () => { const flags = await flg.getEnvironmentFlags(); const flag = flags.getFlag('some_feature'); - }).rejects.toThrow('Error during fetching the API response'); + }).rejects.toThrow('getEnvironmentFlags failed and no default flag handler was provided'); }); test('test_non_200_response_raises_flagsmith_api_error', async () => { @@ -122,8 +98,6 @@ test('test_non_200_response_raises_flagsmith_api_error', async () => { await expect(flg.getEnvironmentFlags()).rejects.toThrow(); }); test('test_default_flag_is_not_used_when_environment_flags_returned', async () => { - fetch.mockResolvedValue(new Response(flagsJSON)); - const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; @@ -161,8 +135,6 @@ test('test_default_flag_is_used_when_bad_api_response_happens', async () => { }); test('test_local_evaluation', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); - const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; diff --git a/tests/sdk/flagsmith-identity-flags.test.ts b/tests/sdk/flagsmith-identity-flags.test.ts index 96e1999..c616fde 100644 --- a/tests/sdk/flagsmith-identity-flags.test.ts +++ b/tests/sdk/flagsmith-identity-flags.test.ts @@ -1,16 +1,18 @@ import Flagsmith from '../../sdk/index.js'; -import { fetch, environmentJSON, flagsmith, identitiesJSON, identityWithTransientTraitsJSON, transientIdentityJSON } from './utils.js'; +import { + fetch, + environmentJSON, + flagsmith, + identitiesJSON, + identityWithTransientTraitsJSON, + transientIdentityJSON, + badFetch +} from './utils.js'; import { DefaultFlag } from '../../sdk/models.js'; vi.mock('../../sdk/polling_manager'); -beforeEach(() => { - vi.clearAllMocks(); -}); - - test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', async () => { - fetch.mockResolvedValue(new Response(identitiesJSON)); const identifier = 'identifier'; const flg = flagsmith(); @@ -23,7 +25,6 @@ test('test_get_identity_flags_calls_api_when_no_local_environment_no_traits', as }); test('test_get_identity_flags_uses_environment_when_local_environment_no_traits', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)) const identifier = 'identifier'; const flg = flagsmith({ @@ -40,7 +41,6 @@ test('test_get_identity_flags_uses_environment_when_local_environment_no_traits' }); test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits', async () => { - fetch.mockResolvedValue(new Response(identitiesJSON)) const identifier = 'identifier'; const traits = { some_trait: 'some_value' }; const flg = flagsmith(); @@ -53,8 +53,6 @@ test('test_get_identity_flags_calls_api_when_no_local_environment_with_traits', }); test('test_default_flag_is_not_used_when_identity_flags_returned', async () => { - fetch.mockResolvedValue(new Response(identitiesJSON)) - const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; @@ -125,7 +123,6 @@ test('test_default_flag_is_used_when_no_identity_flags_returned_and_no_custom_de expect(flag.enabled).toBe(false); }); - test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', async () => { fetch.mockResolvedValue(new Response(environmentJSON)); const identifier = 'identifier'; @@ -133,7 +130,6 @@ test('test_get_identity_flags_multivariate_value_with_local_evaluation_enabled', const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, - }); const identityFlags = (await flg.getIdentityFlags(identifier)) @@ -170,10 +166,10 @@ test('test_transient_identity', async () => { test('test_identity_with_transient_traits', async () => { fetch.mockResolvedValue(new Response(identityWithTransientTraitsJSON)); const identifier = 'transient_trait_identifier'; - const traits = { + const traits = { some_trait: 'some_value', another_trait: {value: 'another_value', transient: true}, - explicitly_non_transient_trait: {value: 'non_transient_value', transient: false} + explicitly_non_transient_trait: {value: 'non_transient_value', transient: false} } const traitsInRequest = [ { @@ -206,3 +202,12 @@ test('test_identity_with_transient_traits', async () => { expect(identityFlags[0].value).toBe('some-identity-with-transient-trait-value'); expect(identityFlags[0].featureName).toBe('some_feature'); }); + +test('getIdentityFlags fails if API call failed and no default flag handler was provided', async () => { + const flg = flagsmith({ + fetch: badFetch, + }) + await expect(flg.getIdentityFlags('user')) + .rejects + .toThrow('getIdentityFlags failed and no default flag handler was provided') +}) diff --git a/tests/sdk/flagsmith.test.ts b/tests/sdk/flagsmith.test.ts index 4e87531..0d8661b 100644 --- a/tests/sdk/flagsmith.test.ts +++ b/tests/sdk/flagsmith.test.ts @@ -1,6 +1,13 @@ import Flagsmith from '../../sdk/index.js'; import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js'; -import { environmentJSON, environmentModel, flagsJSON, flagsmith, fetch, offlineEnvironmentJSON } from './utils.js'; +import { + environmentJSON, + environmentModel, + flagsmith, + fetch, + offlineEnvironmentJSON, + badFetch +} from './utils.js'; import { DefaultFlag, Flags } from '../../sdk/models.js'; import { delay } from '../../sdk/utils.js'; import { EnvironmentModel } from '../../flagsmith-engine/environments/models.js'; @@ -8,12 +15,7 @@ import { BaseOfflineHandler } from '../../sdk/offline_handlers.js'; import { Agent } from 'undici'; vi.mock('../../sdk/polling_manager'); -beforeEach(() => { - vi.clearAllMocks(); -}); - test('test_flagsmith_starts_polling_manager_on_init_if_enabled', () => { - fetch.mockResolvedValue(new Response(environmentJSON)); new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true @@ -22,30 +24,26 @@ test('test_flagsmith_starts_polling_manager_on_init_if_enabled', () => { }); test('test_flagsmith_local_evaluation_key_required', () => { - fetch.mockResolvedValue(new Response(environmentJSON)); - console.error = vi.fn(); - new Flagsmith({ - environmentKey: 'bad.key', - enableLocalEvaluation: true - }); - expect(console.error).toBeCalled(); + expect(() => { + new Flagsmith({ + environmentKey: 'bad.key', + enableLocalEvaluation: true + }); + }).toThrow('Using local evaluation requires a server-side environment key') }); test('test_update_environment_sets_environment', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); - const flg = flagsmith(); - await flg.updateEnvironment(); - expect(flg.environment).toBeDefined(); - + const flg = flagsmith({ + environmentKey: 'ser.key', + }); const model = environmentModel(JSON.parse(environmentJSON)); - - expect(flg.environment).toStrictEqual(model); + expect(await flg.getEnvironment()).toStrictEqual(model); }); test('test_set_agent_options', async () => { const agent = new Agent({}) - fetch.mockImplementation((url, options) => { + fetch.mockImplementationOnce((url, options) => { //@ts-ignore I give up if (options.dispatcher !== agent) { throw new Error("Agent has not been set on retry fetch") @@ -58,11 +56,9 @@ test('test_set_agent_options', async () => { }); await flg.updateEnvironment(); - expect(flg.environment).toBeDefined(); }); test('test_get_identity_segments', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true @@ -75,7 +71,6 @@ test('test_get_identity_segments', async () => { test('test_get_identity_segments_empty_without_local_eval', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: false @@ -85,8 +80,6 @@ test('test_get_identity_segments_empty_without_local_eval', async () => { }); test('test_update_environment_uses_req_when_inited', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); - const flg = flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, @@ -100,7 +93,6 @@ test('test_update_environment_uses_req_when_inited', async () => { }); test('test_isFeatureEnabled_environment', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); const defaultFlag = new DefaultFlag('some-default-value', true); const defaultFlagHandler = (featureName: string) => defaultFlag; @@ -118,9 +110,7 @@ test('test_isFeatureEnabled_environment', async () => { }); test('test_fetch_recovers_after_single_API_error', async () => { - fetch - .mockRejectedValue('Error during fetching the API response') - .mockResolvedValue(new Response(flagsJSON)); + fetch.mockRejectedValueOnce('Error during fetching the API response') const flg = flagsmith({ environmentKey: 'key', }); @@ -132,36 +122,38 @@ test('test_fetch_recovers_after_single_API_error', async () => { expect(flag.value).toBe('some-value'); }); -test('test_default_flag_used_after_multiple_API_errors', async () => { - fetch - .mockRejectedValue(new Error('Error during fetching the API response')); - const defaultFlag = new DefaultFlag('some-default-value', true); - - const defaultFlagHandler = (featureName: string) => defaultFlag; - - const flg = new Flagsmith({ - environmentKey: 'key', - defaultFlagHandler: defaultFlagHandler - }); - - 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.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) => { - await sleep(10000) - return fetch(...args) + const forever = new Promise(() => {}) + await forever + throw new Error('waited forever') }); const defaultFlag = new DefaultFlag('some-default-value', true); - const defaultFlagHandler = (featureName: string) => defaultFlag; + const defaultFlagHandler = () => defaultFlag; - const flg = new Flagsmith({ + const flg = flagsmith({ environmentKey: 'key', defaultFlagHandler: defaultFlagHandler, requestTimeoutSeconds: 0.0001, @@ -184,10 +176,9 @@ test('request timeout uses default if not provided', async () => { }) test('test_throws_when_no_identityFlags_returned_due_to_error', async () => { - fetch.mockResolvedValue(new Response('bad data')); - - const flg = new Flagsmith({ + const flg = flagsmith({ environmentKey: 'key', + fetch: badFetch, }); await expect(async () => await flg.getIdentityFlags('identifier')) @@ -196,34 +187,32 @@ test('test_throws_when_no_identityFlags_returned_due_to_error', async () => { }); test('test onEnvironmentChange is called when provided', async () => { - const callback = { - callback: (e: Error | null, result: EnvironmentModel) => { } - }; - const callbackSpy = vi.spyOn(callback, 'callback'); + const callback = vi.fn() const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, - onEnvironmentChange: callback.callback, + onEnvironmentChange: callback, }); - await delay(200); + fetch.mockRejectedValueOnce(new Error('API error')); + await flg.updateEnvironment().catch(() => { + // Expected rejection + }); - expect(callbackSpy).toBeCalled(); + expect(callback).toBeCalled(); }); test('test onEnvironmentChange is called after error', async () => { - const callback = vi.fn((e, result) => {}) - + const callback = vi.fn(); const flg = new Flagsmith({ environmentKey: 'ser.key', enableLocalEvaluation: true, onEnvironmentChange: callback, + fetch: badFetch, }); - - await delay(200); - - expect(callback).toBeCalled(); + await flg.updateEnvironment(); + expect(callback).toHaveBeenCalled(); }); test('getIdentityFlags throws error if identifier is empty string', async () => { @@ -234,15 +223,15 @@ test('getIdentityFlags throws error if identifier is empty string', async () => await expect(flg.getIdentityFlags('')).rejects.toThrow('`identifier` argument is missing or invalid.'); }) - -test('getIdentitySegments throws error if identifier is empty string', () => { +test('getIdentitySegments throws error if identifier is empty string', async () => { const flg = flagsmith({ environmentKey: 'key', }); - expect(() => { flg.getIdentitySegments(''); }).toThrow('`identifier` argument is missing or invalid.'); -}) - + await expect(flg.getIdentitySegments('')).rejects.toThrow( + '`identifier` argument is missing or invalid.' + ); +}); test('offline_mode', async () => { // Given @@ -286,6 +275,7 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () environmentKey: 'some-key', apiUrl: api_url, offlineHandler: mock_offline_handler, + offlineMode: true }); vi.spyOn(flg, 'getEnvironmentFlags'); @@ -305,10 +295,10 @@ test('test_flagsmith_uses_offline_handler_if_set_and_no_api_response', async () // When const environmentFlags: Flags = await flg.getEnvironmentFlags(); + expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); const identityFlags: Flags = await flg.getIdentityFlags('identity', {}); // Then - expect(mock_offline_handler.getEnvironment).toHaveBeenCalledTimes(1); expect(flg.getEnvironmentFlags).toHaveBeenCalled(); expect(flg.getIdentityFlags).toHaveBeenCalled(); @@ -336,22 +326,37 @@ test('cannot use both default handler and offline handler', () => { test('cannot create Flagsmith client in remote evaluation without API key', () => { // When and Then - expect(() => new Flagsmith()).toThrowError('ValueError: environmentKey is required.'); + expect(() => new Flagsmith({ environmentKey: '' })).toThrowError('ValueError: environmentKey is required.'); }); -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} test('test_localEvaluation_true__identity_overrides_evaluated', async () => { - fetch.mockResolvedValue(new Response(environmentJSON)); + 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 flags = await flg.getIdentityFlags("overridden-id"); - expect(flags.getFeatureValue("some_feature")).toEqual("some-overridden-value"); + 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); }); diff --git a/tests/sdk/polling.test.ts b/tests/sdk/polling.test.ts index d69afb6..65b1cb1 100644 --- a/tests/sdk/polling.test.ts +++ b/tests/sdk/polling.test.ts @@ -3,10 +3,6 @@ import { EnvironmentDataPollingManager } from '../../sdk/polling_manager.js'; import { delay } from '../../sdk/utils.js'; vi.mock('../../sdk'); -beforeEach(() => { - vi.clearAllMocks() -}); - test('test_polling_manager_correctly_stops_if_never_started', async () => { const flagsmith = new Flagsmith({ environmentKey: 'key' diff --git a/tests/sdk/utils.ts b/tests/sdk/utils.ts index b02d38b..a725ca1 100644 --- a/tests/sdk/utils.ts +++ b/tests/sdk/utils.ts @@ -1,8 +1,8 @@ import { readFileSync } from 'fs'; import { buildEnvironmentModel } from '../../flagsmith-engine/environments/util.js'; import { AnalyticsProcessor } from '../../sdk/analytics.js'; -import Flagsmith from '../../sdk/index.js'; -import { FlagsmithCache } from '../../sdk/types.js'; +import Flagsmith, {FlagsmithConfig} from '../../sdk/index.js'; +import { Fetch, FlagsmithCache } from '../../sdk/types.js'; import { Flags } from '../../sdk/models.js'; const DATA_DIR = __dirname + '/data/'; @@ -19,13 +19,33 @@ export class TestCache implements FlagsmithCache { } } -export const fetch = vi.fn(global.fetch) +export const fetch = vi.fn((url: string, options?: RequestInit) => { + const headers = options?.headers as Record; + if (!headers) throw new Error('missing request headers') + const env = headers['X-Environment-Key']; + if (!env) return Promise.resolve(new Response('missing x-environment-key header', { status: 404 })); + if (url.includes('/environment-document')) { + if (env.startsWith('ser.')) { + return Promise.resolve(new Response(environmentJSON, { status: 200 })) + } + return Promise.resolve(new Response('environment-document called without a server-side key', { status: 401 })) + } + if (url.includes("/flags")) { + return Promise.resolve(new Response(flagsJSON, { status: 200 })) + } + if (url.includes("/identities")) { + return Promise.resolve(new Response(identitiesJSON, { status: 200 })) + } + return Promise.resolve(new Response('unknown url ' + url, { status: 404 })) +}); + +export const badFetch: Fetch = () => { throw new Error('fetch failed')} export function analyticsProcessor() { return new AnalyticsProcessor({ environmentKey: 'test-key', analyticsUrl: 'http://testUrl/analytics/flags/', - fetch, + fetch: (url, options) => fetch(url.toString(), options), }); } @@ -33,10 +53,12 @@ export function apiKey(): string { return 'sometestfakekey'; } -export function flagsmith(params = {}) { +export function flagsmith(params: FlagsmithConfig = {}) { return new Flagsmith({ environmentKey: apiKey(), - fetch, + environmentRefreshIntervalSeconds: 0, + requestRetryDelayMilliseconds: 0, + fetch: (url, options) => fetch(url.toString(), options), ...params, }); } diff --git a/vitest.config.ts b/vitest.config.ts index 3e68f84..bbf8292 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, + restoreMocks: true, coverage: { reporter: ['text'], exclude: [