Repository: open-feature/js-sdk
Pull request #585 (2023-10-09 23:17:17)

feat(server): add in memory provider
## This PR

Implements the following features for `inMemoryProvider`:
- default value for flags
- reason for flag evaluation
- Context based evaluation

### Related Issues

It's part of #565 

### Follow-up Tasks

- Update docs

### How to test

Unit tests were also implemented to assure that it is working

toddbaert: Hey @luizgribeiro I think the only thing we need here is the provider code. You can remove the `CHANGELOG.md`, `README.md`, and all the other files (like the tsconfig, etc). Basically just add the actual provider files into /packages/server and we should be good. You can also add some of the doc from `README.md` to the server `README.md`.

There's also a couple lint errors, and some imports are broken (you will need to use relative imports for the stuff from the server package, or import from `@openfeature/shared` for things common to both the web/server SDKs).

Patch:
diff --git a/packages/server/README.md b/packages/server/README.md
index 2d0f8907..d77df13c 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -186,17 +186,28 @@ A name is a logical identifier which can be used to associate clients with a par
 If a name has no associated provider, the global provider is used.
 
 ```ts
-import { OpenFeature } from "@openfeature/js-sdk";
+import { OpenFeature, InMemoryProvider } from "@openfeature/js-sdk";
+
+const myFlags = {
+  'v2_enabled': {
+    variants: {
+      on: true,
+      off: false
+    },
+    disabled: false,
+    defaultVariant: "on"
+  }
+};
 
 // Registering the default provider
-OpenFeature.setProvider(NewLocalProvider());
+OpenFeature.setProvider(InMemoryProvider(myFlags));
 // Registering a named provider
-OpenFeature.setProvider("clientForCache", new NewCachedProvider());
+OpenFeature.setProvider("otherClient", new InMemoryProvider(someOtherFlags));
 
 // A Client backed by default provider
 const clientWithDefault = OpenFeature.getClient();
 // A Client backed by NewCachedProvider
-const clientForCache = OpenFeature.getClient("clientForCache");
+const clientForCache = OpenFeature.getClient("otherClient");
 ```
 
 ### Eventing
diff --git a/packages/server/src/provider/in-memory-provider/flag-configuration.ts b/packages/server/src/provider/in-memory-provider/flag-configuration.ts
new file mode 100644
index 00000000..0ca3e435
--- /dev/null
+++ b/packages/server/src/provider/in-memory-provider/flag-configuration.ts
@@ -0,0 +1,36 @@
+/**
+ * Don't export types from this file publicly.
+ * It might cause confusion since these types are not a part of the general API,
+ * but just for the in-memory provider.
+ */
+import { EvaluationContext, JsonValue } from '@openfeature/shared';
+
+type Variants<T> = Record<string, T>;
+
+/**
+ * A Feature Flag definition, containing it's specification
+ */
+export type Flag = {
+  /**
+   * An object containing all possible flags mappings (variant -> flag value)
+   */
+  variants: Variants<boolean> | Variants<string> | Variants<number> | Variants<JsonValue>;
+  /**
+   * The variant it will resolve to in STATIC evaluation
+   */
+  defaultVariant: string;
+  /**
+   * Determines if flag evaluation is enabled or not for this flag.
+   * If false, falls back to the default value provided to the client
+   */
+  disabled: boolean;
+  /**
+   * Function used in order to evaluate a flag to a specific value given the provided context.
+   * It should return a variant key.
+   * If it does not return a valid variant it falls back to the default value provided to the client
+   * @param EvaluationContext
+   */
+  contextEvaluator?: (ctx: EvaluationContext) => string;
+};
+
+export type FlagConfiguration = Record<string, Flag>;
diff --git a/packages/server/src/provider/in-memory-provider/in-memory-provider.ts b/packages/server/src/provider/in-memory-provider/in-memory-provider.ts
new file mode 100644
index 00000000..223be418
--- /dev/null
+++ b/packages/server/src/provider/in-memory-provider/in-memory-provider.ts
@@ -0,0 +1,139 @@
+import {
+  EvaluationContext,
+  FlagNotFoundError,
+  FlagValueType,
+  GeneralError,
+  JsonValue,
+  Logger,
+  OpenFeatureError,
+  OpenFeatureEventEmitter,
+  ProviderEvents,
+  ResolutionDetails,
+  StandardResolutionReasons,
+  TypeMismatchError
+} from '@openfeature/shared';
+import { Provider } from '../provider';
+import { Flag, FlagConfiguration } from './flag-configuration';
+import { VariantFoundError } from './variant-not-found-error';
+
+/**
+ * A simple OpenFeature provider intended for demos and as a test stub.
+ */
+export class InMemoryProvider implements Provider {
+  public readonly events = new OpenFeatureEventEmitter();
+  public readonly runsOn = 'server';
+  readonly metadata = {
+    name: 'in-memory',
+  } as const;
+  private _flagConfiguration: FlagConfiguration;
+
+  constructor(flagConfiguration: FlagConfiguration = {}) {
+    this._flagConfiguration = { ...flagConfiguration };
+  }
+
+  /**
+   * Overwrites the configured flags.
+   * @param { FlagConfiguration } flagConfiguration new flag configuration
+   */
+  putConfiguration(flagConfiguration: FlagConfiguration) {
+    const flagsChanged = Object.entries(flagConfiguration)
+      .filter(([key, value]) => this._flagConfiguration[key] !== value)
+      .map(([key]) => key);
+
+    this._flagConfiguration = { ...flagConfiguration };
+    this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged });
+  }
+
+  resolveBooleanEvaluation(
+    flagKey: string,
+    defaultValue: boolean,
+    context?: EvaluationContext,
+    logger?: Logger,
+  ): Promise<ResolutionDetails<boolean>> {
+    return this.resolveFlagWithReason<boolean>(flagKey, defaultValue, context, logger);
+  }
+
+  resolveNumberEvaluation(
+    flagKey: string,
+    defaultValue: number,
+    context?: EvaluationContext,
+    logger?: Logger,
+  ): Promise<ResolutionDetails<number>> {
+    return this.resolveFlagWithReason<number>(flagKey, defaultValue, context, logger);
+  }
+
+  async resolveStringEvaluation(
+    flagKey: string,
+    defaultValue: string,
+    context?: EvaluationContext,
+    logger?: Logger,
+  ): Promise<ResolutionDetails<string>> {
+    return this.resolveFlagWithReason<string>(flagKey, defaultValue, context, logger);
+  }
+
+  async resolveObjectEvaluation<T extends JsonValue>(
+    flagKey: string,
+    defaultValue: T,
+    context?: EvaluationContext,
+    logger?: Logger,
+  ): Promise<ResolutionDetails<T>> {
+    return this.resolveFlagWithReason<T>(flagKey, defaultValue, context, logger);
+  }
+
+  private async resolveFlagWithReason<T extends JsonValue | FlagValueType>(
+    flagKey: string,
+    defaultValue: T,
+    ctx?: EvaluationContext,
+    logger?: Logger,
+  ): Promise<ResolutionDetails<T>> {
+    try {
+      const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger);
+
+      if (typeof resolutionResult?.value != typeof defaultValue) {
+        throw new TypeMismatchError();
+      }
+
+      return resolutionResult;
+    } catch (error: unknown) {
+      if (!(error instanceof OpenFeatureError)) {
+        throw new GeneralError((error as Error)?.message || 'unknown error'); 
+      }
+      throw error;
+    }
+  }
+
+  private lookupFlagValue<T extends JsonValue | FlagValueType>(
+    flagKey: string,
+    defaultValue: T,
+    ctx?: EvaluationContext,
+    logger?: Logger,
+  ): ResolutionDetails<T> {
+    if (!(flagKey in this._flagConfiguration)) {
+      const message = `no flag found with key ${flagKey}`;
+      logger?.debug(message);
+      throw new FlagNotFoundError(message);
+    }
+    const flagSpec: Flag = this._flagConfiguration[flagKey];
+
+    if (flagSpec.disabled) {
+      return { value: defaultValue, reason: StandardResolutionReasons.DISABLED };
+    }
+
+    const isContextEval = ctx && flagSpec?.contextEvaluator;
+    const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant;
+
+    const value = variant && flagSpec?.variants[variant];
+
+    if (value === undefined) {
+      const message = `no value associated with variant ${variant}`;
+      logger?.error(message);
+      throw new VariantFoundError(message);
+    }
+
+    return {
+      value: value as T,
+      ...(variant && { variant }),
+      reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC,
+    };
+  }
+}
diff --git a/packages/server/src/provider/in-memory-provider/index.ts b/packages/server/src/provider/in-memory-provider/index.ts
new file mode 100644
index 00000000..1bf0d537
--- /dev/null
+++ b/packages/server/src/provider/in-memory-provider/index.ts
@@ -0,0 +1,1 @@
+export * from './in-memory-provider';
diff --git a/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts b/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts
new file mode 100644
index 00000000..d7542e84
--- /dev/null
+++ b/packages/server/src/provider/in-memory-provider/variant-not-found-error.ts
@@ -0,0 +1,15 @@
+import { ErrorCode, OpenFeatureError } from '@openfeature/shared';
+
+/**
+ * A custom error for the in-memory provider.
+ * Indicates the resolved or default variant doesn't exist.
+ */
+export class VariantFoundError extends OpenFeatureError {
+  code: ErrorCode;
+  constructor(message?: string) {
+    super(message);
+    Object.setPrototypeOf(this, VariantFoundError.prototype);
+    this.name = 'VariantFoundError';
+    this.code = ErrorCode.GENERAL;
+  }
+}
diff --git a/packages/server/src/provider/index.ts b/packages/server/src/provider/index.ts
index 79eff210..33e12fea 100644
--- a/packages/server/src/provider/index.ts
+++ b/packages/server/src/provider/index.ts
@@ -1,2 +1,3 @@
 export * from './provider';
 export * from './no-op-provider';
+export * from './in-memory-provider';


Test patch:
diff --git a/packages/server/test/in-memory-provider.spec.ts b/packages/server/test/in-memory-provider.spec.ts
new file mode 100644
index 00000000..28880e21
--- /dev/null
+++ b/packages/server/test/in-memory-provider.spec.ts
@@ -0,0 +1,581 @@
+import { ErrorCode, FlagNotFoundError, ProviderEvents, StandardResolutionReasons, TypeMismatchError } from '@openfeature/shared';
+import { InMemoryProvider } from '../src';
+import { VariantFoundError } from '../src/provider/in-memory-provider/variant-not-found-error';
+
+describe(InMemoryProvider, () => {
+  describe('boolean flags', () => {
+    const provider = new InMemoryProvider({});
+    it('resolves to default variant with reason static', async () => {
+      const booleanFlagSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(booleanFlagSpec);
+
+      const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', true);
+
+      expect(resolution).toEqual({ value: true, reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+
+    it('throws FlagNotFound if flag does not exist', async () => {
+      const booleanFlagSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(booleanFlagSpec);
+
+      await expect(provider.resolveBooleanEvaluation('another-boolean-flag', false)).rejects.toThrow();
+    });
+
+    it('resolves to default value with reason disabled if flag is disabled', async () => {
+      const booleanFlagDisabledSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: true,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(booleanFlagDisabledSpec);
+
+      const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', false);
+
+      expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.DISABLED });
+    });
+
+    it('throws VariantFoundError if variant does not exist', async () => {
+      const booleanFlagDisabledSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(booleanFlagDisabledSpec);
+
+      await expect(provider.resolveBooleanEvaluation('a-boolean-flag', false)).rejects.toThrow(VariantFoundError);
+    });
+
+    it('throws TypeMismatchError if variant type does not match with accessors', async () => {
+      const booleanFlagSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: 'yes',
+            off: 'no',
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(booleanFlagSpec);
+
+      await expect(provider.resolveBooleanEvaluation('a-boolean-flag', false)).rejects.toThrow(TypeMismatchError);
+    });
+
+    it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => {
+      const booleanFlagCtxSpec = {
+        'a-boolean-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+          contextEvaluator: () => 'off',
+        },
+      };
+      provider.putConfiguration(booleanFlagCtxSpec);
+      const dummyContext = {};
+
+      const resolution = await provider.resolveBooleanEvaluation('a-boolean-flag', true, dummyContext);
+
+      expect(resolution).toEqual({ value: false, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' });
+    });
+  });
+
+  describe('string flags', () => {
+    const provider = new InMemoryProvider({});
+    const itsDefault = "it's deafault";
+    const itsOn = "it's on";
+    const itsOff = "it's off";
+    it('resolves to default variant with reason static', async () => {
+      const stringFlagSpec = {
+        'a-string-flag': {
+          variants: {
+            on: itsOn,
+            off: itsOff,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(stringFlagSpec);
+
+      const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault);
+
+      expect(resolution).toEqual({ value: itsOn, reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+
+    it('throws FlagNotFound if flag does not exist', async () => {
+      const StringFlagSpec = {
+        'a-string-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+
+      provider.putConfiguration(StringFlagSpec);
+
+      await expect(provider.resolveStringEvaluation('another-string-flag', itsDefault)).rejects.toThrow(FlagNotFoundError);
+    });
+
+    it('resolves to default value with reason disabled if flag is disabled', async () => {
+      const StringFlagDisabledSpec = {
+        'a-string-flag': {
+          variants: {
+            on: itsOn,
+            off: itsOff,
+          },
+          disabled: true,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(StringFlagDisabledSpec);
+
+      const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault);
+
+      expect(resolution).toEqual({ value: itsDefault, reason: StandardResolutionReasons.DISABLED });
+    });
+
+    it('throws VariantFoundError if variant does not exist', async () => {
+      const StringFlagSpec = {
+        'a-string-flag': {
+          variants: {
+            on: itsOn,
+            off: itsOff,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(StringFlagSpec);
+
+      await expect(provider.resolveStringEvaluation('a-string-flag', itsDefault)).rejects.toThrow(VariantFoundError);
+    });
+
+    it('throws TypeMismatchError if variant does not match with accessor method type', async () => {
+      const StringFlagSpec = {
+        'a-string-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+
+      provider.putConfiguration(StringFlagSpec);
+
+      await expect(provider.resolveStringEvaluation('a-string-flag', itsDefault)).rejects.toThrow(TypeMismatchError);
+    });
+
+    it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => {
+      const StringFlagCtxSpec = {
+        'a-string-flag': {
+          variants: {
+            on: itsOn,
+            off: itsOff,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+          contextEvaluator: () => 'off',
+        },
+      };
+      provider.putConfiguration(StringFlagCtxSpec);
+      const dummyContext = {};
+
+      const resolution = await provider.resolveStringEvaluation('a-string-flag', itsDefault, dummyContext);
+
+      expect(resolution).toEqual({ value: itsOff, reason: StandardResolutionReasons.TARGETING_MATCH, variant: 'off' });
+    });
+  });
+
+  describe('number flags', () => {
+    const provider = new InMemoryProvider({});
+    const defaultNumber = 42;
+    const onNumber = -528;
+    const offNumber = 0;
+    it('resolves to default variant with reason static', async () => {
+      const numberFlagSpec = {
+        'a-number-flag': {
+          variants: {
+            on: onNumber,
+            off: offNumber,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(numberFlagSpec);
+
+      const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber);
+
+      expect(resolution).toEqual({ value: onNumber, reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+
+    it('throws FlagNotFound if flag does not exist', async () => {
+      const numberFlagSpec = {
+        'a-number-flag': {
+          variants: {
+            on: onNumber,
+            off: offNumber,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(numberFlagSpec);
+
+      await expect(provider.resolveNumberEvaluation('another-number-flag', defaultNumber)).rejects.toThrow(FlagNotFoundError);
+    });
+
+    it('resolves to default value with reason disabled if flag is disabled', async () => {
+      const numberFlagDisabledSpec = {
+        'a-number-flag': {
+          variants: {
+            on: onNumber,
+            off: offNumber,
+          },
+          disabled: true,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(numberFlagDisabledSpec);
+
+      const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber);
+
+      expect(resolution).toEqual({ value: defaultNumber, reason: StandardResolutionReasons.DISABLED });
+    });
+
+    it('throws VariantNotFoundError if variant does not exist', async () => {
+      const numberFlagSpec = {
+        'a-number-flag': {
+          variants: {
+            on: onNumber,
+            off: offNumber,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(numberFlagSpec);
+
+      await expect(provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).rejects.toThrow(VariantFoundError);
+    });
+
+    it('throws TypeMismatchError if variant does not match with accessor method type', async () => {
+      const numberFlagSpec = {
+        'a-number-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+
+      provider.putConfiguration(numberFlagSpec);
+
+      await expect(provider.resolveNumberEvaluation('a-number-flag', defaultNumber)).rejects.toThrow(TypeMismatchError);
+    });
+
+    it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => {
+      const numberFlagCtxSpec = {
+        'a-number-flag': {
+          variants: {
+            on: onNumber,
+            off: offNumber,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+          contextEvaluator: () => 'off',
+        },
+      };
+      provider.putConfiguration(numberFlagCtxSpec);
+      const dummyContext = {};
+
+      const resolution = await provider.resolveNumberEvaluation('a-number-flag', defaultNumber, dummyContext);
+
+      expect(resolution).toEqual({
+        value: offNumber,
+        reason: StandardResolutionReasons.TARGETING_MATCH,
+        variant: 'off',
+      });
+    });
+  });
+
+  describe('Object flags', () => {
+    const provider = new InMemoryProvider({});
+    const defaultObject = { someKey: 'default' };
+    const onObject = { someKey: 'on' };
+    const offObject = { someKey: 'off' };
+    it('resolves to default variant with reason static', async () => {
+      const ObjectFlagSpec = {
+        'a-object-flag': {
+          variants: {
+            on: onObject,
+            off: offObject,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(ObjectFlagSpec);
+
+      const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject);
+
+      expect(resolution).toEqual({ value: onObject, reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+
+    it('throws FlagNotFound if flag does not exist', async () => {
+      const ObjectFlagSpec = {
+        'a-Object-flag': {
+          variants: {
+            on: onObject,
+            off: offObject,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(ObjectFlagSpec);
+
+      await expect(provider.resolveObjectEvaluation('another-number-flag', defaultObject)).rejects.toThrow(FlagNotFoundError);
+    });
+
+    it('resolves to default value with reason disabled if flag is disabled', async () => {
+      const ObjectFlagDisabledSpec = {
+        'a-object-flag': {
+          variants: {
+            on: onObject,
+            off: offObject,
+          },
+          disabled: true,
+          defaultVariant: 'on',
+        },
+      };
+      provider.putConfiguration(ObjectFlagDisabledSpec);
+
+      const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject);
+
+      expect(resolution).toEqual({ value: defaultObject, reason: StandardResolutionReasons.DISABLED });
+    });
+
+    it('throws VariantFoundError if variant does not exist', async () => {
+      const ObjectFlagSpec = {
+        'a-Object-flag': {
+          variants: {
+            on: onObject,
+            off: offObject,
+          },
+          disabled: false,
+          defaultVariant: 'dummy',
+        },
+      };
+      provider.putConfiguration(ObjectFlagSpec);
+
+      await expect(provider.resolveObjectEvaluation('a-Object-flag', defaultObject)).rejects.toThrow(VariantFoundError);
+    });
+
+    it('throws TypeMismatchError if variant does not match with accessor method type', async () => {
+      const ObjectFlagSpec = {
+        'a-object-flag': {
+          variants: {
+            on: true,
+            off: false,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+        },
+      };
+
+      provider.putConfiguration(ObjectFlagSpec);
+
+      await expect(provider.resolveObjectEvaluation('a-object-flag', defaultObject)).rejects.toThrow(TypeMismatchError);
+    });
+
+    it('resolves to variant value with reason target match if context is provided and flag spec has context evaluator', async () => {
+      const ObjectFlagCtxSpec = {
+        'a-object-flag': {
+          variants: {
+            on: onObject,
+            off: offObject,
+          },
+          disabled: false,
+          defaultVariant: 'on',
+          contextEvaluator: () => 'off',
+        },
+      };
+      provider.putConfiguration(ObjectFlagCtxSpec);
+      const dummyContext = {};
+
+      const resolution = await provider.resolveObjectEvaluation('a-object-flag', defaultObject, dummyContext);
+
+      expect(resolution).toEqual({
+        value: offObject,
+        reason: StandardResolutionReasons.TARGETING_MATCH,
+        variant: 'off',
+      });
+    });
+  });
+
+  describe('events', () => {
+    it('emits provider changed event if a new value is added', (done) => {
+      const flagsSpec = {
+        'some-flag': {
+          variants: {
+            on: 'initial-value',
+          },
+          defaultVariant: 'on',
+          disabled: false,
+        },
+      };
+      const provider = new InMemoryProvider(flagsSpec);
+
+      provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => {
+        expect(details?.flagsChanged).toEqual(['some-other-flag']);
+        done();
+      });
+
+      const newFlag = {
+        'some-other-flag': {
+          variants: {
+            off: 'some-other-value',
+          },
+          defaultVariant: 'off',
+          disabled: false,
+        },
+      };
+
+      const newflagsSpec = { ...flagsSpec, ...newFlag };
+      provider.putConfiguration(newflagsSpec);
+    });
+
+    it('emits provider changed event if an existing value is changed', (done) => {
+      const flagsSpec = {
+        'some-flag': {
+          variants: {
+            on: 'initial-value',
+          },
+          defaultVariant: 'on',
+          disabled: false,
+        },
+      };
+      const provider = new InMemoryProvider(flagsSpec);
+
+      provider.events.addHandler(ProviderEvents.ConfigurationChanged, (details) => {
+        expect(details?.flagsChanged).toEqual(['some-flag']);
+        done();
+      });
+
+      const newFlagSpec = {
+        'some-flag': {
+          variants: {
+            off: 'some-other-value',
+          },
+          defaultVariant: 'off',
+          disabled: false,
+        },
+      };
+      provider.putConfiguration(newFlagSpec);
+    });
+  });
+
+  describe('Flags configuration', () => {
+    it('reflects changes in flag configuration', async () => {
+      const provider = new InMemoryProvider({
+        'some-flag': {
+          variants: {
+            on: 'initial-value',
+          },
+          defaultVariant: 'on',
+          disabled: false,
+        },
+      });
+
+      const firstResolution = await provider.resolveStringEvaluation('some-flag', 'deafaultFirstResolution');
+
+      expect(firstResolution).toEqual({
+        value: 'initial-value',
+        reason: StandardResolutionReasons.STATIC,
+        variant: 'on',
+      });
+
+      provider.putConfiguration({
+        'some-flag': {
+          variants: {
+            on: 'new-value',
+          },
+          defaultVariant: 'on',
+          disabled: false,
+        },
+      });
+
+      const secondResolution = await provider.resolveStringEvaluation('some-flag', 'defaultSecondResolution');
+
+      expect(secondResolution).toEqual({ value: 'new-value', reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+
+    it('does not let you change values with the configuration passed by reference', async () => {
+      const flagsSpec = {
+        'some-flag': {
+          variants: {
+            on: 'initial-value',
+          },
+          defaultVariant: 'on',
+          disabled: false,
+        },
+      };
+
+      const substituteSpec = {
+        variants: {
+          on: 'some-other-value',
+        },
+        defaultVariant: 'on',
+        disabled: false,
+      };
+
+      const provider = new InMemoryProvider(flagsSpec);
+
+      // I passed configuration by reference, so maybe I can mess
+      // with it behind the providers back!
+      flagsSpec['some-flag'] = substituteSpec;
+
+      const resolution = await provider.resolveStringEvaluation('some-flag', 'default value');
+      expect(resolution).toEqual({ value: 'initial-value', reason: StandardResolutionReasons.STATIC, variant: 'on' });
+    });
+  });
+});