From 3b2d04527b51eeacdf4bf6520d09939fabf37a20 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Sat, 21 May 2022 00:27:23 +0200 Subject: [PATCH] Add api, client Co-authored-by: Michael Beemer --- .eslintrc.json | 13 +- .prettierrc | 3 +- package-lock.json | 17 ++ package.json | 3 +- src/client.ts | 157 ++++++++++++++++++ src/constants.ts | 5 + src/index.ts | 8 +- src/no-op-provider.ts | 35 ++++ src/open-feature.ts | 59 +++++++ src/types.ts | 197 ++++++++++++++++++++++ test/client.spec.ts | 338 ++++++++++++++++++++++++++++++++++++++ test/index.spec.ts | 8 - test/open-feature.spec.ts | 45 +++++ 13 files changed, 871 insertions(+), 17 deletions(-) create mode 100644 src/client.ts create mode 100644 src/constants.ts create mode 100644 src/no-op-provider.ts create mode 100644 src/open-feature.ts create mode 100644 src/types.ts create mode 100644 test/client.spec.ts delete mode 100644 test/index.spec.ts create mode 100644 test/open-feature.spec.ts diff --git a/.eslintrc.json b/.eslintrc.json index 1d47ba53..9e56cb90 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,7 +14,8 @@ "sourceType":"module" }, "plugins":[ - "@typescript-eslint" + "@typescript-eslint", + "check-file" ], "rules":{ "linebreak-style":[ @@ -28,6 +29,14 @@ "semi":[ "error", "always" - ] + ], + "check-file/filename-naming-convention":[ + "error", + { + "*.spec.{js,ts}":"*", + "**/jest.config.ts":"*", + "*.{js,ts}":"KEBAB_CASE" + } + ] } } diff --git a/.prettierrc b/.prettierrc index 544138be..0981b7cc 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,4 @@ { - "singleQuote": true + "singleQuote": true, + "printWidth": 120 } diff --git a/package-lock.json b/package-lock.json index daa726fb..9ee20661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1811,6 +1811,17 @@ "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", "dev": true }, + "eslint-plugin-check-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-check-file/-/eslint-plugin-check-file-1.1.0.tgz", + "integrity": "sha512-OFLBvqFIDVR0F5gI497UhK/swBZRGq9oJLpChfF3wsh+SxmPP5yzkjOS5gQKj2OupiUMpBrPDcq29zH2j873Nw==", + "dev": true, + "requires": { + "is-glob": "^4.0.3", + "micromatch": "^4.0.4", + "requireindex": "^1.2.0" + } + }, "eslint-plugin-jest": { "version": "26.1.5", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-26.1.5.tgz", @@ -3533,6 +3544,12 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, + "requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true + }, "resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", diff --git a/package.json b/package.json index 7376ff54..fd76c77b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", "scripts": { - "test": "jest", + "test": "jest --verbose", "lint": "eslint ./", "postbuild": "cp ./package.esm.json ./dist/esm/package.json", "build": "rm -f -R ./dist && tsc --project tsconfig.json && tsc --project tsconfig.cjs.json" @@ -43,6 +43,7 @@ "@typescript-eslint/parser": "^5.23.0", "eslint": "^8.14.0", "eslint-config-prettier": "^8.5.0", + "eslint-plugin-check-file": "^1.1.0", "eslint-plugin-jest": "^26.1.5", "jest": "^28.1.0", "jest-junit": "^13.2.0", diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 00000000..0db4a4a5 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,157 @@ +import { ERROR_REASON, GENERAL_ERROR } from './constants'; +import { OpenFeature } from './open-feature'; +import { + Client, + EvaluationContext, + EvaluationDetails, + FlagEvaluationOptions, + FlagValue, + Hook, + ResolutionDetails, + TransformingProvider, +} from './types'; + +type OpenFeatureClientOptions = { + name?: string; + version?: string; +}; + +export class OpenFeatureClient implements Client { + name?: string | undefined; + version?: string | undefined; + readonly context: EvaluationContext; + + constructor(private readonly api: OpenFeature, options: OpenFeatureClientOptions, context: EvaluationContext = {}) { + this.name = options.name; + this.version = options.version; + this.context = context; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addHooks(...hooks: Hook[]): void { + throw new Error('Method not implemented.'); + } + + get hooks(): Hook[] { + throw new Error('Method not implemented.'); + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise { + return (await this.getBooleanDetails(flagKey, defaultValue, context, options)).value; + } + + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise> { + return this.evaluate(flagKey, this.provider.resolveBooleanEvaluation, defaultValue, context, options); + } + + async getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise { + return (await this.getStringDetails(flagKey, defaultValue, context, options)).value; + } + + getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise> { + return this.evaluate(flagKey, this.provider.resolveStringEvaluation, defaultValue, context, options); + } + + async getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise { + return (await this.getNumberDetails(flagKey, defaultValue, context, options)).value; + } + + getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise> { + return this.evaluate(flagKey, this.provider.resolveNumberEvaluation, defaultValue, context, options); + } + + async getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise { + return (await this.getObjectDetails(flagKey, defaultValue, context, options)).value; + } + + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise> { + return this.evaluate(flagKey, this.provider.resolveObjectEvaluation, defaultValue, context, options); + } + + private async evaluate( + flagKey: string, + resolver: ( + flagKey: string, + defaultValue: T, + transformedContext: unknown, + options: FlagEvaluationOptions | undefined + ) => Promise>, + defaultValue: T, + context: EvaluationContext = {}, + options: FlagEvaluationOptions = {} + ): Promise> { + // merge global, client, and evaluation context + const mergedContext = { + ...this.api.context, + ...this.context, + ...context, + }; + + try { + // if a transformer is defined, run it to prepare the context. + const transformedContext = + typeof this.provider.contextTransformer === 'function' + ? await this.provider.contextTransformer(mergedContext) + : mergedContext; + + // run the referenced resolver, binding the provider. + const resolution = await resolver.call(this.provider, flagKey, defaultValue, transformedContext, options); + return { + ...resolution, + flagKey, + }; + } catch (err: unknown) { + const errorCode = (!!err && (err as { code: string }).code) || GENERAL_ERROR; + return { + errorCode, + value: defaultValue, + reason: ERROR_REASON, + flagKey, + }; + } + } + + private get provider() { + return OpenFeature.instance.provider as TransformingProvider; + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..6f872d88 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,5 @@ +// reasons +export const ERROR_REASON = 'ERROR'; + +// error-codes +export const GENERAL_ERROR = 'GENERAL_ERROR'; diff --git a/src/index.ts b/src/index.ts index 8eaad5ba..d714b7bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -// real code will go here, just scaffolding the project for now. -export const greet = (greeting: string): string => { - const message = `${greeting}, OpenFeature`; - return message; -}; +export * from './open-feature'; +export * from './client'; +export * from './types'; diff --git a/src/no-op-provider.ts b/src/no-op-provider.ts new file mode 100644 index 00000000..280fdfa3 --- /dev/null +++ b/src/no-op-provider.ts @@ -0,0 +1,35 @@ +import { Provider, ResolutionDetails } from './types'; + +const REASON_NO_OP = 'No-op'; + +/** + * The No-op provider is set by default, and simply always returns the default value. + */ +class NoopFeatureProvider implements Provider { + readonly name = 'No-op Provider'; + + resolveBooleanEvaluation(_: string, defaultValue: boolean): Promise> { + return this.noOp(defaultValue); + } + + resolveStringEvaluation(_: string, defaultValue: string): Promise> { + return this.noOp(defaultValue); + } + + resolveNumberEvaluation(_: string, defaultValue: number): Promise> { + return this.noOp(defaultValue); + } + + resolveObjectEvaluation(_: string, defaultValue: T): Promise> { + return this.noOp(defaultValue); + } + + private noOp(defaultValue: T) { + return Promise.resolve({ + value: defaultValue, + reason: REASON_NO_OP, + }); + } +} + +export const NOOP_PROVIDER = new NoopFeatureProvider(); diff --git a/src/open-feature.ts b/src/open-feature.ts new file mode 100644 index 00000000..a9306c87 --- /dev/null +++ b/src/open-feature.ts @@ -0,0 +1,59 @@ +import { OpenFeatureClient } from './client'; +import { NOOP_PROVIDER } from './no-op-provider'; +import { Client, EvaluationContext, EvaluationLifeCycle, FlagValue, Hook, Provider } from './types'; + +// use a symbol as a key for the global singleton +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/js.api'); + +type OpenFeatureGlobal = { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeature; +}; +const _global = global as OpenFeatureGlobal; + +export class OpenFeature implements EvaluationLifeCycle { + private _provider: Provider = NOOP_PROVIDER; + private _context: EvaluationContext = {}; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static get instance(): OpenFeature { + const globalApi = _global[GLOBAL_OPENFEATURE_API_KEY]; + if (globalApi) { + return globalApi; + } + + const instance = new OpenFeature(); + _global[GLOBAL_OPENFEATURE_API_KEY] = instance; + return instance; + } + + getClient(name?: string, version?: string, context?: EvaluationContext): Client { + return new OpenFeatureClient(this, { name, version }, context); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addHooks(...hooks: Hook[]): void { + throw new Error('Method not implemented.'); + } + + get hooks(): Hook[] { + throw new Error('Method not implemented.'); + } + + set provider(provider: Provider) { + this._provider = provider; + } + + get provider(): Provider { + return this._provider; + } + + set context(context: EvaluationContext) { + this._context = context; + } + + get context(): EvaluationContext { + return this._context; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..af07e347 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,197 @@ +export type EvaluationContext = { + /** + * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. + * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. Such providers may behave unpredictably if a targeting key is not specified at flag resolution. + */ + targetingKey?: string; +} & Record; + +export type FlagValue = boolean | string | number | object; + +export interface FlagEvaluationOptions { + hooks?: Hook[]; +} + +export interface Features { + /** + * Get a boolean flag value. + */ + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise; + + /** + * Get a boolean flag with additional details. + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise>; + + /** + * Get a string flag value. + */ + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise; + + /** + * Get a string flag with additional details. + */ + getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise>; + + /** + * Get a number flag value. + */ + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise; + + /** + * Get a number flag with additional details. + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise>; + + /** + * Get an object (JSON) flag value. + */ + getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise; + + /** + * Get an object (JSON) flag with additional details. + */ + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions + ): Promise>; +} + +/** + * Function which transforms the EvaluationContext to a type useful for the provider. + */ +export type ContextTransformer = (context: EvaluationContext) => T; + +interface GenericProvider { + name: string; + + /** + * Resolve a boolean flag and it's evaluation details. + */ + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + transformedContext: T, + options: FlagEvaluationOptions | undefined + ): Promise>; + + /** + * Resolve a string flag and it's evaluation details. + */ + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + transformedContext: T, + options: FlagEvaluationOptions | undefined + ): Promise>; + + /** + * Resolve a numeric flag and it's evaluation details. + */ + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + transformedContext: T, + options: FlagEvaluationOptions | undefined + ): Promise>; + + /** + * Resolve and parse an object flag and it's evaluation details. + */ + resolveObjectEvaluation( + flagKey: string, + defaultValue: U, + transformedContext: T, + options: FlagEvaluationOptions | undefined + ): Promise>; +} + +export type NonTransformingProvider = GenericProvider; + +export interface TransformingProvider extends GenericProvider { + contextTransformer: ContextTransformer | T> | undefined; +} + +/** + * Interface that providers must implement to resolve flag values for their particular + * backend or vendor. + * + * Implementation for resolving all the required flag types must be defined. + * + * Additionally, a ContextTransformer function that transforms the OpenFeature context to the requisite user/context/attribute representation (typeof T) + * may also be implemented. This function will run immediately before the flag value resolver functions, appropriately transforming the context. + */ +export type Provider = T extends EvaluationContext + ? NonTransformingProvider + : TransformingProvider; + +export interface EvaluationLifeCycle { + addHooks(...hooks: Hook[]): void; + get hooks(): Hook[]; +} + +export interface ProviderOptions { + contextTransformer?: ContextTransformer; +} + +export type ResolutionDetails = { + value: U; + variant?: string; + reason?: string; + errorCode?: string; +}; + +export type EvaluationDetails = { + flagKey: string; +} & ResolutionDetails; + +export interface Client extends EvaluationLifeCycle, Features { + readonly name?: string; + readonly version?: string; +} + +export type HookContext = { + // TODO: implement with hooks +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Hook { + // TODO: implement with hooks +} diff --git a/test/client.spec.ts b/test/client.spec.ts new file mode 100644 index 00000000..bb47b18c --- /dev/null +++ b/test/client.spec.ts @@ -0,0 +1,338 @@ +import { OpenFeatureClient } from '../src/client'; +import { ERROR_REASON, GENERAL_ERROR } from '../src/constants'; +import { OpenFeature } from '../src/open-feature'; +import { + Client, + EvaluationContext, + EvaluationDetails, + NonTransformingProvider, + Provider, + ResolutionDetails, + TransformingProvider, +} from '../src/types'; + +const BOOLEAN_VALUE = true; +const STRING_VALUE = 'val'; +const NUMBER_VALUE = 2034; +const OBJECT_VALUE = { + key: 'value', +}; +const BOOLEAN_VARIANT = `${BOOLEAN_VALUE}`; +const STRING_VARIANT = `${STRING_VALUE}-variant`; +const NUMBER_VARIANT = NUMBER_VALUE.toString(); +const OBJECT_VARIANT = OBJECT_VALUE.key; +const REASON = 'mocked-value'; + +// a mock provider with some jest spies +const MOCK_PROVIDER: Provider = { + name: 'mock', + + resolveBooleanEvaluation: jest.fn((): Promise> => { + return Promise.resolve({ + value: BOOLEAN_VALUE, + variant: BOOLEAN_VARIANT, + reason: REASON, + }); + }), + resolveStringEvaluation: jest.fn((): Promise> => { + return Promise.resolve({ + value: STRING_VALUE, + variant: STRING_VARIANT, + reason: REASON, + }); + }), + resolveNumberEvaluation: jest.fn((): Promise> => { + return Promise.resolve({ + value: NUMBER_VALUE, + variant: NUMBER_VARIANT, + reason: REASON, + }); + }), + resolveObjectEvaluation: jest.fn((): Promise> => { + const details = Promise.resolve>({ + value: OBJECT_VALUE as unknown as U, + variant: OBJECT_VARIANT, + reason: REASON, + }); + return details as Promise>; + }) as () => Promise>, +}; + +describe(OpenFeatureClient.name, () => { + beforeAll(() => { + OpenFeature.instance.provider = MOCK_PROVIDER; + }); + + describe('Requirement 1.6', () => { + it('should allow addition of hooks', () => { + // TODO: implement with hooks + }); + }); + + describe('Requirement 1.7, 1.8', () => { + let client: Client; + + beforeEach(() => { + client = OpenFeature.instance.getClient(); + }); + + describe('flag evaluation', () => { + describe(` ${OpenFeatureClient.prototype.getBooleanValue.name}`, () => { + it('should return boolean, and call boolean resolver', async () => { + const booleanFlag = 'my-boolean-flag'; + const defaultBooleanValue = false; + const value = await client.getBooleanValue(booleanFlag, defaultBooleanValue); + + expect(value).toEqual(BOOLEAN_VALUE); + expect(MOCK_PROVIDER.resolveBooleanEvaluation).toHaveBeenCalledWith(booleanFlag, defaultBooleanValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getStringValue.name, () => { + it('should return string, and call string resolver', async () => { + const stringFlag = 'my-string-flag'; + const defaultStringValue = 'default-value'; + const value = await client.getStringValue(stringFlag, defaultStringValue); + + expect(value).toEqual(STRING_VALUE); + expect(MOCK_PROVIDER.resolveStringEvaluation).toHaveBeenCalledWith(stringFlag, defaultStringValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getNumberValue.name, () => { + it('should return number, and call number resolver', async () => { + const numberFlag = 'my-number-flag'; + const defaultNumberValue = 1970; + const value = await client.getNumberValue(numberFlag, defaultNumberValue); + + expect(value).toEqual(NUMBER_VALUE); + expect(MOCK_PROVIDER.resolveNumberEvaluation).toHaveBeenCalledWith(numberFlag, defaultNumberValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getObjectValue.name, () => { + it('should return object, and call object resolver', async () => { + const objectFlag = 'my-object-flag'; + const defaultObjectFlag = {}; + const value = await client.getObjectValue(objectFlag, {}); + + expect(value).toEqual(OBJECT_VALUE); + expect(MOCK_PROVIDER.resolveObjectEvaluation).toHaveBeenCalledWith(objectFlag, defaultObjectFlag, {}, {}); + }); + }); + }); + }); + + describe('Requirement 1.9, 1.10', () => { + let client: Client; + + beforeEach(() => { + client = OpenFeature.instance.getClient(); + }); + + describe('detailed flag evaluation', () => { + describe(` ${OpenFeatureClient.prototype.getBooleanDetails.name}`, () => { + it('should return boolean details, and call boolean resolver', async () => { + const booleanFlag = 'my-boolean-flag'; + const defaultBooleanValue = false; + const booleanDetails = await client.getBooleanDetails(booleanFlag, defaultBooleanValue); + + expect(booleanDetails.value).toEqual(BOOLEAN_VALUE); + expect(booleanDetails.variant).toEqual(BOOLEAN_VARIANT); + expect(MOCK_PROVIDER.resolveBooleanEvaluation).toHaveBeenCalledWith(booleanFlag, defaultBooleanValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getStringDetails.name, () => { + it('should return string details, and call string resolver', async () => { + const stringFlag = 'my-string-flag'; + const defaultStringValue = 'default-value'; + const stringDetails = await client.getStringDetails(stringFlag, defaultStringValue); + + expect(stringDetails.value).toEqual(STRING_VALUE); + expect(stringDetails.variant).toEqual(STRING_VARIANT); + expect(MOCK_PROVIDER.resolveStringEvaluation).toHaveBeenCalledWith(stringFlag, defaultStringValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getNumberDetails.name, () => { + it('should return number details, and call number resolver', async () => { + const numberFlag = 'my-number-flag'; + const defaultNumberValue = 1970; + const numberDetails = await client.getNumberDetails(numberFlag, defaultNumberValue); + + expect(numberDetails.value).toEqual(NUMBER_VALUE); + expect(numberDetails.variant).toEqual(NUMBER_VARIANT); + expect(MOCK_PROVIDER.resolveNumberEvaluation).toHaveBeenCalledWith(numberFlag, defaultNumberValue, {}, {}); + }); + }); + + describe(OpenFeatureClient.prototype.getObjectDetails.name, () => { + it('should return object details, and call object resolver', async () => { + const objectFlag = 'my-object-flag'; + const defaultObjectFlag = {}; + const objectDetails = await client.getObjectDetails(objectFlag, defaultObjectFlag); + + expect(objectDetails.value).toEqual(OBJECT_VALUE); + expect(objectDetails.variant).toEqual(OBJECT_VARIANT); + expect(MOCK_PROVIDER.resolveObjectEvaluation).toHaveBeenCalledWith(objectFlag, defaultObjectFlag, {}, {}); + }); + }); + }); + }); + + describe('Requirement 1.11', () => { + describe('generic support', () => { + it('should support generic', async () => { + // No generic information exists at runtime, but this test has some value in ensuring the generic args still exist in the typings. + type MyType = { key: string }; + const client = OpenFeature.instance.getClient(); + const details: ResolutionDetails = await client.getObjectDetails('flag', { key: 'value' }); + + expect(details).toBeDefined(); + }); + }); + }); + + describe('Evaluation details structure', () => { + const flagKey = 'number-details'; + const defaultValue = 1970; + let details: EvaluationDetails; + + describe('Normal execution', () => { + beforeAll(async () => { + const client = OpenFeature.instance.getClient(); + details = await client.getNumberDetails(flagKey, defaultValue); + + expect(details).toBeDefined(); + }); + + describe('Requirement 1.10, 1.11', () => { + it('should contain flag value', () => { + expect(details.value).toEqual(NUMBER_VALUE); + }); + }); + + describe('Requirement 1.12', () => { + it('should contain flag key', () => { + expect(details.flagKey).toEqual(flagKey); + }); + }); + + describe('Requirement 1.13', () => { + it('should contain flag variant', () => { + expect(details.variant).toEqual(NUMBER_VARIANT); + }); + }); + + describe('Requirement 1.14', () => { + it('should contain reason', () => { + expect(details.reason).toEqual(REASON); + }); + }); + }); + + describe('Abnormal execution', () => { + let details: EvaluationDetails; + let client: Client; + const errorProvider = { + name: 'error-mock', + + resolveNumberEvaluation: jest.fn((): Promise> => { + throw new Error('Fake error!'); + }), + } as unknown as Provider; + const defaultValue = 123; + + beforeAll(async () => { + OpenFeature.instance.provider = errorProvider; + client = OpenFeature.instance.getClient(); + details = await client.getNumberDetails('some-flag', defaultValue); + }); + + describe('Requirement 1.18', () => { + it('must not throw, must return default', async () => { + details = await client.getNumberDetails('some-flag', defaultValue); + + expect(details).toBeTruthy(); + expect(details.value).toEqual(defaultValue); + }); + }); + + describe('Requirement 1.15', () => { + it('should contain error', () => { + expect(details.errorCode).toBeTruthy(); + expect(details.errorCode).toEqual(GENERAL_ERROR); + }); + }); + + describe('Requirement 1.15', () => { + it('should contain "error" reason', () => { + expect(details.reason).toEqual(ERROR_REASON); + }); + }); + }); + }); + + describe('Requirement 1.21', () => { + describe('Transforming provider', () => { + const transformingProvider = { + name: 'transforming', + // a simple context transformer that just adds a property (transformed: true) + contextTransformer: jest.fn((context: EvaluationContext) => { + return { ...context, transformed: true }; + }), + resolveBooleanEvaluation: jest.fn((): Promise> => { + return Promise.resolve({ + value: true, + }); + }), + } as unknown as TransformingProvider; + it('should run context transformer, and pass transformed context to resolver', async () => { + const flagKey = 'some-flag'; + const defaultValue = false; + const context = {}; + OpenFeature.instance.provider = transformingProvider; + const client = OpenFeature.instance.getClient(); + await client.getBooleanValue(flagKey, defaultValue, context); + + // expect transformer was called with context + expect(transformingProvider.contextTransformer).toHaveBeenCalledWith(context); + // expect transformed context was passed to resolver. + expect(transformingProvider.resolveBooleanEvaluation).toHaveBeenCalledWith( + flagKey, + defaultValue, + expect.objectContaining({ transformed: true }), + expect.anything() + ); + }); + }); + + describe('Non-transforming provider', () => { + const nonTransformingProvider = { + name: 'non-transforming', + resolveBooleanEvaluation: jest.fn((): Promise> => { + return Promise.resolve({ + value: true, + }); + }), + } as unknown as NonTransformingProvider; + it('should pass context to resolver', async () => { + const flagKey = 'some-other-flag'; + const defaultValue = false; + const context = { transformed: false }; + OpenFeature.instance.provider = nonTransformingProvider; + const client = OpenFeature.instance.getClient(); + await client.getBooleanValue(flagKey, defaultValue, context); + + // expect context was passed to resolver. + expect(nonTransformingProvider.resolveBooleanEvaluation).toHaveBeenCalledWith( + flagKey, + defaultValue, + expect.objectContaining({ transformed: false }), + expect.anything() + ); + }); + }); + }); +}); diff --git a/test/index.spec.ts b/test/index.spec.ts deleted file mode 100644 index 278e84c3..00000000 --- a/test/index.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { greet } from '../src/index'; - -describe('greet', () => { - it('should return greeting', () => { - const result = greet('hi'); - expect(result).toContain('hi'); - }); -}); diff --git a/test/open-feature.spec.ts b/test/open-feature.spec.ts new file mode 100644 index 00000000..1943e7f5 --- /dev/null +++ b/test/open-feature.spec.ts @@ -0,0 +1,45 @@ +import { OpenFeatureClient } from '../src/client'; +import { OpenFeature } from '../src/open-feature'; +import { Provider } from '../src/types'; + +describe(OpenFeature.name, () => { + describe('Requirement 1.1', () => { + it('should be global singleton', () => { + expect(OpenFeature.instance.provider === OpenFeature.instance.provider).toBeTruthy(); + }); + }); + + describe('Requirement 1.2', () => { + it('should be set provider', () => { + const fakeProvider = {} as Provider; + OpenFeature.instance.provider = fakeProvider; + expect(OpenFeature.instance.provider === fakeProvider).toBeTruthy(); + }); + }); + + describe('Requirement 1.3', () => { + it('should allow addition of hooks', () => { + // TODO: implement with hooks + }); + }); + + describe('Requirement 1.4', () => { + it('should implement a hook accessor', () => { + expect(OpenFeature.instance.provider).toBeDefined(); + }); + }); + + describe('Requirement 1.5', () => { + it('should implement a client factory', () => { + expect(OpenFeature.instance.getClient).toBeDefined(); + expect(OpenFeature.instance.getClient()).toBeInstanceOf(OpenFeatureClient); + + const name = 'my-client'; + const namedClient = OpenFeature.instance.getClient(name); + + // check that using a named configuration also works as expected. + expect(namedClient).toBeInstanceOf(OpenFeatureClient); + expect(namedClient.name).toEqual(name); + }); + }); +});