diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts index fc82bc908..0f48dfe83 100644 --- a/packages/shared/src/open-feature.ts +++ b/packages/shared/src/open-feature.ts @@ -27,6 +27,8 @@ type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; */ export class ProviderWrapper

, S extends AnyProviderStatus> { private _pendingContextChanges = 0; + private _initializing = false; + private _initialized = false; constructor( private _provider: P, @@ -49,6 +51,8 @@ export class ProviderWrapper

, S exte this._status = _statusEnumType.ERROR as S; } }); + + this._initialized = !(typeof _provider.initialize === 'function'); } get provider(): P { @@ -67,6 +71,22 @@ export class ProviderWrapper

, S exte this._status = status; } + get initializing() { + return this._initializing; + } + + set initializing(initializing: boolean) { + this._initializing = initializing; + } + + get initialized() { + return this._initialized; + } + + set initialized(initialized: boolean) { + this._initialized = initialized; + } + get allContextChangesSettled() { return this._pendingContextChanges === 0; } @@ -208,16 +228,67 @@ export abstract class OpenFeatureCommonAPI< abstract setProviderAndWait( clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | Record, + optionsOrUndefined?: Record, ): Promise; abstract setProvider( clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | Record, + optionsOrUndefined?: Record, ): this; - protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { + protected initializeProviderForDomain(wrapper: ProviderWrapper, domain?: string): Promise | void { + if (typeof wrapper.provider.initialize !== 'function' + || wrapper.initializing + || wrapper.initialized) { + return; + } + + wrapper.initializing = true; + return wrapper.provider + .initialize(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) + .then(() => { + wrapper.status = this._statusEnumType.READY; + const payload = { clientName: domain, domain, providerName: wrapper.provider.metadata.name }; + + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { ...payload }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { ...payload }); + }) + .catch((error) => { + // if this is a fatal error, transition to FATAL status + if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { + wrapper.status = this._statusEnumType.FATAL; + } else { + wrapper.status = this._statusEnumType.ERROR; + } + const payload = { + clientName: domain, + domain, + providerName: wrapper.provider.metadata.name, + message: error?.message, + }; + + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Error, { ...payload }); + }); + this._apiEmitter?.emit(AllProviderEvents.Error, { ...payload }); + + // rethrow after emitting error events, so that public methods can control error handling + throw error; + }) + .finally(() => { + wrapper.initialized = true; + wrapper.initializing = false; + }); + } + + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P, skipInitialization = false): Promise | void { const domain = stringOrUndefined(domainOrProvider); const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); @@ -250,48 +321,19 @@ export abstract class OpenFeatureCommonAPI< this._statusEnumType, ); - // initialize the provider if it implements "initialize" and it's not already registered - if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) { - initializationPromise = provider - .initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) - ?.then(() => { - wrappedProvider.status = this._statusEnumType.READY; - // fetch the most recent event emitters, some may have been added during init - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }) - ?.catch((error) => { - // if this is a fatal error, transition to FATAL status - if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { - wrappedProvider.status = this._statusEnumType.FATAL; - } else { - wrappedProvider.status = this._statusEnumType.ERROR; - } - this.getAssociatedEventEmitters(domain).forEach((emitter) => { - emitter?.emit(AllProviderEvents.Error, { - clientName: domain, - domain, - providerName, - message: error?.message, - }); - }); - this._apiEmitter?.emit(AllProviderEvents.Error, { - clientName: domain, - domain, - providerName, - message: error?.message, - }); - // rethrow after emitting error events, so that public methods can control error handling - throw error; + if (!skipInitialization) { + // initialize the provider if it's not already registered and it implements "initialize" + if (!this.allProviders.includes(provider)) { + initializationPromise = this.initializeProviderForDomain(wrappedProvider, domain); + } + + if (!initializationPromise) { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); }); - } else { - wrappedProvider.status = this._statusEnumType.READY; - emitters.forEach((emitter) => { - emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); - }); - this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + } } if (domain) { diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index be6c7f845..f175f23f0 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -16,6 +16,17 @@ import type { Hook } from './hooks'; import type { Provider} from './provider'; import { NOOP_PROVIDER, ProviderStatus } from './provider'; +interface ProviderOptions { + /** + * If provided, will be used to check if the current context is valid during initialization and context changes. + * When calling `setProvider`, returning `false` will skip provider initialization. Throwing will move the provider to the ERROR state. + * When calling `setProviderAndWait`, returning `false` will skip provider initialization. Throwing will reject the promise. + * When calling `setContext`, returning `false` will skip provider reconciliation of the context change. Throwing will move the provider to the ERROR state. + * @param context The evaluation context to validate. + */ + validateContext?: (context: EvaluationContext) => boolean; +} + // use a symbol as a key for the global singleton const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); @@ -42,6 +53,8 @@ export class OpenFeatureAPI ); protected _domainScopedProviders: Map> = new Map(); protected _createEventEmitter = () => new OpenFeatureEventEmitter(); + protected _defaultOptions: ProviderOptions = {}; + protected _domainScopedOptions: Map = new Map(); private constructor() { super('client'); @@ -71,6 +84,14 @@ export class OpenFeatureAPI return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status; } + private getProviderOptions(domain?: string): ProviderOptions { + if (!domain) { + return this._defaultOptions; + } + + return this._domainScopedOptions.get(domain) ?? this._defaultOptions; + } + /** * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. @@ -86,10 +107,11 @@ export class OpenFeatureAPI * Setting a provider supersedes the current provider used in new and existing unbound clients. * @param {Provider} provider The provider responsible for flag evaluations. * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {Promise} * @throws {Error} If the provider throws an exception during initialization. */ - setProviderAndWait(provider: Provider, context: EvaluationContext): Promise; + setProviderAndWait(provider: Provider, context: EvaluationContext, options?: ProviderOptions): Promise; /** * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. * A promise is returned that resolves when the provider is ready. @@ -107,24 +129,47 @@ export class OpenFeatureAPI * @param {string} domain The name to identify the client * @param {Provider} provider The provider responsible for flag evaluations. * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {Promise} * @throws {Error} If the provider throws an exception during initialization. */ - setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext): Promise; + setProviderAndWait(domain: string, provider: Provider, context: EvaluationContext, options?: ProviderOptions): Promise; async setProviderAndWait( clientOrProvider?: string | Provider, providerContextOrUndefined?: Provider | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | ProviderOptions, + optionsOrUndefined?: ProviderOptions, ): Promise { const domain = stringOrUndefined(clientOrProvider); const provider = domain ? objectOrUndefined(providerContextOrUndefined) : objectOrUndefined(clientOrProvider); const context = domain - ? objectOrUndefined(contextOrUndefined) + ? objectOrUndefined(contextOptionsOrUndefined) : objectOrUndefined(providerContextOrUndefined); + const options = domain + ? objectOrUndefined(optionsOrUndefined) + : objectOrUndefined(contextOptionsOrUndefined); + if (domain) { + this._domainScopedOptions.set(domain, options ?? {}); + } else { + this._defaultOptions = options ?? {}; + } + + let skipInitialization = false; if (context) { + // validate the context to decide if we should initialize the provider with it. + if (typeof options?.validateContext === 'function') { + // allow any error to propagate here to reject the promise. + skipInitialization = !options.validateContext(context); + if (skipInitialization) { + this._logger.debug( + `Skipping provider initialization during setProviderAndWait for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + } + } + // synonymously setting context prior to provider initialization. // No context change event will be emitted. if (domain) { @@ -134,7 +179,7 @@ export class OpenFeatureAPI } } - await this.setAwaitableProvider(domain, provider); + await this.setAwaitableProvider(domain, provider, skipInitialization); } /** @@ -150,10 +195,11 @@ export class OpenFeatureAPI * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. * Setting a provider supersedes the current provider used in new and existing unbound clients. * @param {Provider} provider The provider responsible for flag evaluations. - * @param context {EvaluationContext} The evaluation context to use for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {this} OpenFeature API */ - setProvider(provider: Provider, context: EvaluationContext): this; + setProvider(provider: Provider, context: EvaluationContext, options?: ProviderOptions): this; /** * Sets the provider for flag evaluations of providers with the given name. * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. @@ -167,24 +213,56 @@ export class OpenFeatureAPI * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. * @param {string} domain The name to identify the client * @param {Provider} provider The provider responsible for flag evaluations. - * @param context {EvaluationContext} The evaluation context to use for flag evaluations. + * @param {EvaluationContext} context The evaluation context to use for flag evaluations. + * @param {ProviderOptions} [options] Options for setting the provider. * @returns {this} OpenFeature API */ - setProvider(domain: string, provider: Provider, context: EvaluationContext): this; + setProvider(domain: string, provider: Provider, context: EvaluationContext, options?: ProviderOptions): this; setProvider( domainOrProvider?: string | Provider, providerContextOrUndefined?: Provider | EvaluationContext, - contextOrUndefined?: EvaluationContext, + contextOptionsOrUndefined?: EvaluationContext | ProviderOptions, + optionsOrUndefined?: ProviderOptions, ): this { const domain = stringOrUndefined(domainOrProvider); const provider = domain ? objectOrUndefined(providerContextOrUndefined) : objectOrUndefined(domainOrProvider); const context = domain - ? objectOrUndefined(contextOrUndefined) + ? objectOrUndefined(contextOptionsOrUndefined) : objectOrUndefined(providerContextOrUndefined); + const options = domain + ? objectOrUndefined(optionsOrUndefined) + : objectOrUndefined(contextOptionsOrUndefined); + if (domain) { + this._domainScopedOptions.set(domain, options ?? {}); + } else { + this._defaultOptions = options ?? {}; + } + + let skipInitialization = false; + let validateContextError: unknown; if (context) { + // validate the context to decide if we should initialize the provider with it. + if (typeof options?.validateContext === 'function') { + try { + skipInitialization = !options.validateContext(context); + if (skipInitialization) { + this._logger.debug( + `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + } + } catch (err) { + // capture the error to move the provider to ERROR state after setting it. + validateContextError = err; + skipInitialization = true; + this._logger.debug( + `Skipping provider initialization during setProvider for domain '${domain ?? 'default'}' due to validateContext throwing an error.`, + ); + } + } + // synonymously setting context prior to provider initialization. // No context change event will be emitted. if (domain) { @@ -194,7 +272,30 @@ export class OpenFeatureAPI } } - const maybePromise = this.setAwaitableProvider(domain, provider); + const maybePromise = this.setAwaitableProvider(domain, provider, skipInitialization); + + // If there was a validation error with the context, move the newly created provider to ERROR state. + // We know we've skipped initialization if this happens, so no need to worry about the promise changing the state later. + if (validateContextError) { + const wrapper = domain + ? this._domainScopedProviders.get(domain) + : this._defaultProvider; + if (wrapper) { + wrapper.status = this._statusEnumType.ERROR; + const payload = { + clientName: domain, + domain, + providerName: wrapper.provider?.metadata?.name || 'unnamed-provider', + message: `Error validating context during setProvider: ${validateContextError instanceof Error ? validateContextError.message : String(validateContextError)}`, + }; + + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(ProviderEvents.Error, { ...payload }); + }); + this._apiEmitter?.emit(ProviderEvents.Error, { ...payload }); + this._logger.error('Error validating context during setProvider:', validateContextError); + } + } // The setProvider method doesn't return a promise so we need to catch and // log any errors that occur during provider initialization to avoid having @@ -389,6 +490,26 @@ export class OpenFeatureAPI const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider'; try { + // validate the context to decide if we should run the context change handler. + // notably, the stored context will still be updated by this point, this just skips reconciliation. + const options = this.getProviderOptions(domain); + if (typeof options.validateContext === 'function') { + if (!options.validateContext(newContext)) { + this._logger.debug( + `Skipping context change for domain '${domain ?? 'default'}' due to validateContext returning false.`, + ); + return; + } + } + + // if the provider hasn't initialized yet, and isn't actively initializing, initialize instead of running context change handler + // the provider will be in this state if validateContext was used during setProvider to skip initialization + const initializationPromise = this.initializeProviderForDomain(wrapper, domain); + if (initializationPromise) { + await initializationPromise; + return; + } + if (typeof wrapper.provider.onContextChange === 'function') { const maybePromise = wrapper.provider.onContextChange(oldContext, newContext); diff --git a/packages/web/test/evaluation-context.spec.ts b/packages/web/test/evaluation-context.spec.ts index 288301c88..3d2064ba7 100644 --- a/packages/web/test/evaluation-context.spec.ts +++ b/packages/web/test/evaluation-context.spec.ts @@ -1,7 +1,7 @@ import type { EvaluationContext, JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src'; import { OpenFeature } from '../src'; -const initializeMock = jest.fn(); +const initializeMock = jest.fn().mockResolvedValue(undefined); class MockProvider implements Provider { readonly metadata: ProviderMetadata; diff --git a/packages/web/test/validate-context.spec.ts b/packages/web/test/validate-context.spec.ts new file mode 100644 index 000000000..24a6e090b --- /dev/null +++ b/packages/web/test/validate-context.spec.ts @@ -0,0 +1,303 @@ +import type { JsonValue, Provider, ProviderMetadata, ResolutionDetails } from '../src'; +import { NOOP_PROVIDER, OpenFeature, ProviderStatus } from '../src'; + +const initializeMock = jest.fn().mockResolvedValue(undefined); +const contextChangeMock = jest.fn().mockResolvedValue(undefined); + +class MockProvider implements Provider { + readonly metadata: ProviderMetadata; + + constructor(options?: { name?: string }) { + this.metadata = { name: options?.name ?? 'mock-provider' }; + } + + initialize = initializeMock; + onContextChange = contextChangeMock; + + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } +} + +describe('validateContext', () => { + afterEach(async () => { + await OpenFeature.clearContexts(); + await OpenFeature.setProviderAndWait(NOOP_PROVIDER, {}); + jest.clearAllMocks(); + }); + + describe('when validateContext is not provided', () => { + it('should call initialize on setProvider', async () => { + const provider = new MockProvider(); + OpenFeature.setProvider(provider, {}); + + await new Promise(process.nextTick); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + await OpenFeature.setProviderAndWait(provider, {}); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should not call initialize on context change', async () => { + const provider = new MockProvider(); + await OpenFeature.setProviderAndWait(provider, {}); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + }); + }); + + describe('when validateContext evaluates to true', () => { + it('should call initialize on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + it('should call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + describe('when the provider is initialized', () => { + it('should not call initialize again on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should call initialize on the first valid context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockReturnValue(true); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'another-user' }); + + expect(validateContext).toHaveBeenCalledTimes(3); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'another-user' }); + }); + }); + }); + + describe('when validateContext evaluates to false', () => { + it('should not call initialize on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + }); + + it('should not call initialize on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + }); + + describe('when the provider is initialized', () => { + it('should not process a context change that fails validation', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + validateContext.mockReturnValue(false); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should not call initialize until a valid context is provided', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + OpenFeature.setProvider(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockReturnValue(true); + await OpenFeature.setContext({ user: 'another-user' }); + + expect(validateContext).toHaveBeenCalledTimes(3); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'another-user' }); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + await OpenFeature.setContext({ user: 'final-user' }); + + expect(validateContext).toHaveBeenCalledTimes(4); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getContext()).toEqual({ user: 'final-user' }); + }); + }); + }); + + describe('when validateContext throws an error', () => { + it('should move to ERROR status on setProvider', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockImplementation(() => { + throw new Error('Validation error'); + }); + OpenFeature.setProvider(provider, {}, { validateContext }); + + await new Promise(process.nextTick); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + + it('should propagate error on setProviderAndWait', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockImplementation(() => { + throw new Error('Validation error'); + }); + + await expect(OpenFeature.setProviderAndWait(provider, {}, { validateContext })).rejects.toThrow( + 'Validation error', + ); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getProvider()).toBe(NOOP_PROVIDER); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + }); + + describe('when the provider is initialized', () => { + it('should move to ERROR status on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(true); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.READY); + + validateContext.mockImplementation(() => { + throw new Error('Validation error'); + }); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + }); + + describe('when the provider is not yet initialized', () => { + it('should move to ERROR status on context change', async () => { + const provider = new MockProvider(); + const validateContext = jest.fn().mockReturnValue(false); + await OpenFeature.setProviderAndWait(provider, {}, { validateContext }); + + expect(validateContext).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.NOT_READY); + + validateContext.mockImplementation(() => { + throw new Error('Validation error'); + }); + await OpenFeature.setContext({ user: 'test-user' }); + + expect(validateContext).toHaveBeenCalledTimes(2); + expect(initializeMock).toHaveBeenCalledTimes(0); + expect(contextChangeMock).toHaveBeenCalledTimes(0); + expect(OpenFeature.getContext()).toEqual({ user: 'test-user' }); + expect(OpenFeature.getClient().providerStatus).toBe(ProviderStatus.ERROR); + }); + }); + }); +});