From ded00aba5f45a5e15b33523d50a1a77fe2b42512 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Jul 2022 13:32:33 -0700 Subject: [PATCH 1/6] Base implementation, add test file and start to write tests. --- .../integrations/test_data/TestData.test.ts | 11 + .../src/api/integrations/TestData.ts | 377 ---------------- .../src/integrations/test_data/TestData.ts | 172 ++++++++ .../test_data/TestDataFlagBuilder.ts | 405 ++++++++++++++++++ .../test_data/TestDataRuleBuilder.ts | 138 ++++++ .../integrations/test_data/TestDataSource.ts | 38 ++ .../test_data/booleanVariation.ts | 6 + .../src/options/Configuration.ts | 15 +- server-sdk-common/src/store/serialization.ts | 10 +- 9 files changed, 786 insertions(+), 386 deletions(-) create mode 100644 server-sdk-common/__tests__/integrations/test_data/TestData.test.ts delete mode 100644 server-sdk-common/src/api/integrations/TestData.ts create mode 100644 server-sdk-common/src/integrations/test_data/TestData.ts create mode 100644 server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts create mode 100644 server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts create mode 100644 server-sdk-common/src/integrations/test_data/TestDataSource.ts create mode 100644 server-sdk-common/src/integrations/test_data/booleanVariation.ts diff --git a/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts new file mode 100644 index 0000000000..7d76ce3e73 --- /dev/null +++ b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts @@ -0,0 +1,11 @@ +import { LDClientImpl } from '../../../src'; +import TestData from '../../../src/integrations/test_data/TestData'; +import InMemoryFeatureStore from '../../../src/store/InMemoryFeatureStore'; + +it('initializes the datastore with flags configured before the client is started', () => { + const td = new TestData(); + td.update(td.flag('new-flag').variationForAll(true)); + + const store = new InMemoryFeatureStore(); + // const client = new LDClientImpl('sdk_key', { offline: true, featureStore: store, updateProcessor: td }); +}); \ No newline at end of file diff --git a/server-sdk-common/src/api/integrations/TestData.ts b/server-sdk-common/src/api/integrations/TestData.ts deleted file mode 100644 index 1ff6279f49..0000000000 --- a/server-sdk-common/src/api/integrations/TestData.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK - * client in test scenarios. - * - * Unlike [[FileData]], this mechanism does not use any external resources. It provides only the - * data that the application has put into it using the [[TestData.update]] method. - * - * @example - * const { TestData } = require('launchdarkly-node-server-sdk/interfaces'); - * - * const td = TestData(); - * testData.update(td.flag("flag-key-1").booleanFlag().variationForAll(true)); - * const client = new LDClient(sdkKey, { updateProcessor: td }); - * - * // flags can be updated at any time: - * td.update(td.flag("flag-key-2") - * .variationForContext("user", "some-user-key", true) - * .fallthroughVariation(false)); - * - * The above example uses a simple boolean flag, but more complex configurations are possible using - * the methods of the [[TestDataFlagBuilder]] that is returned by [[TestData.flag]]. - * [[TestDataFlagBuilder]] supports many of the ways a flag can be configured on the LaunchDarkly - * dashboard, but does not currently support - * 1. rule operators other than "in" and "not in", or - * 2. percentage rollouts. - * - * If the same `TestData` instance is used to configure multiple `LDClient` instances, - * any changes made to the data will propagate to all of the `LDClient`s. - * - * @see [[FileDataSource]] - */ -export interface TestData { - /** - * Creates or copies a [[TestDataFlagBuilder]] for building a test flag configuration. - * - * If the flag key has already been defined in this `TestData` instance, - * then the builder starts with the same configuration that was last - * provided for this flag. - * - * Otherwise, it starts with a new default configuration in which the flag - * has `true` and `false` variations, is `true` for all users when targeting - * is turned on and `false` otherwise, and currently has targeting turned on. - * You can change any of those properties and provide more complex behavior - * using the `TestDataFlagBuilder` methods. - * - * Once you have set the desired configuration, pass the builder to - * [[TestData.update]]. - * - * @param key the flag key - * @returns a flag configuration builder - * - */ - flag(key: string): TestDataFlagBuilder; - - /** - * Updates the test data with the specified flag configuration. - * - * This has the same effect as if a flag were added or modified in the - * LaunchDarkly dashboard. It immediately propagates the flag changes to - * any [[LDClient]] instance(s) that you have already configured to use - * this `TestData`. If no `LDClient` has been started yet, it simply adds - * this flag to the test data which will be provided to any `LDClient` - * that you subsequently configure. - * - * Any subsequent changes to this `TestDataFlagBuilder` instance do not affect - * the test data unless you call `update` again. - * - * @param flagBuilder a flag configuration builder - * @return a promise that will resolve when the feature stores are updated - */ - update(flagBuilder: TestDataFlagBuilder): Promise; - - /** - * Copies a full feature flag data model object into the test data. - * - * It immediately propagates the flag change to any [[LDClient]] instance(s) that you have already - * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds this - * flag to the test data which will be provided to any LDClient that you subsequently configure. - * - * Use this method if you need to use advanced flag configuration properties that are not - * supported by the simplified [[TestDataFlagBuilder]] API. Otherwise it is recommended to use the - * regular [[flag]]/[[update]] mechanism to avoid dependencies on details of the data model. - * - * You cannot make incremental changes with [[flag]]/[[update]] to a flag that has been added in - * this way; you can only replace it with an entirely new flag configuration. - * - * @param flagConfig the flag configuration as a JSON object - * @return a promise that will resolve when the feature stores are updated - */ - usePreconfiguredFlag(flagConfig: any): Promise; - - /** - * Copies a full segment data model object into the test data. - * - * It immediately propagates the change to any [[LDClient]] instance(s) that you have already - * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds - * this segment to the test data which will be provided to any LDClient that you subsequently - * configure. - * - * This method is currently the only way to inject segment data, since there is no builder - * API for segments. It is mainly intended for the SDK's own tests of segment functionality, - * since application tests that need to produce a desired evaluation state could do so more easily - * by just setting flag values. - * - * @param segmentConfig the segment configuration as a JSON object - * @return a promise that will resolve when the feature stores are updated - */ - usePreconfiguredSegment(segmentConfig: any): Promise; -} - -/** - * A builder for feature flag configurations to be used with [[TestData]]. - */ - -export interface TestDataFlagBuilder { - /** - * A shortcut for setting the flag to use the standard boolean configuration. - * - * This is the default for all new flags created with [[TestData.flag]]. The - * flag will have two variations, `true` and `false` (in that order). It - * will return `false` whenever targeting is off and `true` when targeting - * is on unless other settings specify otherwise. - * - * @return the flag builder - */ - booleanFlag(): TestDataFlagBuilder; - - /** - * Sets the allowable variation values for the flag. - * - * The values may be of any JSON-compatible type: boolean, number, string, array, - * or object. For instance, a boolean flag normally has `variations(true, false)`; - * a string-valued flag might have `variations("red", "green")`; etc. - * - * @param values any number of variation values - * @return the flag builder - */ - variations(...values: any[]): TestDataFlagBuilder; - - /** - * Sets targeting to be on or off for this flag. - * - * The effect of this depends on the rest of the flag configuration, just - * as it does on the real LaunchDarkly dashboard. In the default configuration - * that you get from calling [[TestData.flag]] with a new flag key, the flag - * will return `false` whenever targeting is off and `true` when targeting - * is on. - * - * @param targetingOn true if targeting should be on - * @return the flag builder - */ - on(targetingOn: boolean): TestDataFlagBuilder; - - /** - * Specifies the fallthrough variation for a flag. The fallthrough is - * the value that is returned if targeting is on and the user was not - * matched by a more specific target or rule. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param variation - * either `true` or `false` or the index of the desired fallthrough - * variation: 0 for the first, 1 for the second, etc. - * @return the flag builder - */ - fallthroughVariation(variation: boolean | number): TestDataFlagBuilder; - - /** - * Specifies the off variation for a flag. This is the variation that is - * returned whenever targeting is off. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param variation - * either `true` or `false` or the index of the desired off - * variation: 0 for the first, 1 for the second, etc. - * @return the flag builder - */ - offVariation(variation: boolean | number): TestDataFlagBuilder; - - /** - * Sets the flag to always return the specified variation for all contexts. - * - * Targeting is switched on, any existing targets or rules are removed, - * and the fallthrough variation is set to the specified value. The off - * variation is left unchanged. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param varation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ - variationForAll(variation: boolean | number): TestDataFlagBuilder; - - /** - * Sets the flag to always return the specified variation value for all contexts. - * - * The value may be of any valid JSON type. This method changes the flag to have - * only a single variation, which is this value, and to return the same variation - * regardless of whether targeting is on or off. Any existing targets or rules - * are removed. - * - * @param value The desired value to be returned for all contexts. - * @return the flag builder - */ - valueForAll(value: any): TestDataFlagBuilder; - - /** - * Sets the flag to return the specified variation for a specific context key - * when targeting is on. The context kind for contexts created with this method - * will be 'user'. - * - * This has no effect when targeting is turned off for the flag. - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - * - * @param contextKey a context key - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ - variationForUser(contextKey: string, variation: boolean | number): TestDataFlagBuilder; - - /** - * Sets the flag to return the specified variation for a specific context key - * when targeting is on. - * - * This has no effect when targeting is turned off for the flag. - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - * - * @param contextKind a context kind - * @param contextKey a context key - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ - variationForContext(contextKind: string, contextKey: string, - variation: boolean | number): TestDataFlagBuilder; - - /** - * Removes any existing rules from the flag. This undoes the effect of methods - * like [[ifMatch]]. - * - * @return the same flag builder - */ - clearRules(): TestDataFlagBuilder; - - /** - * Removes any existing targets from the flag. This undoes the effect of - * methods like [[variationForContext]]. - * - * @return the same flag builder - */ - clearAlltargets(): TestDataFlagBuilder; - - /** - * Starts defining a flag rule using the "is one of" operator. - * - * For example, this creates a rule that returnes `true` if the name is - * "Patsy" or "Edina": - * - * testData.flag('flag') - * .ifMatch('user', name', 'Patsy', 'Edina') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the context attribute to match against - * @param values values to compare to - * @return - * a flag rule builder; call `thenReturn` to finish the rule - * or add more tests with another method like `andMatch` - */ - ifMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder; - - /** - * Starts defining a flag rule using the "is not one of" operator. - * - * For example, this creates a rule that returnes `true` if the name is - * neither "Saffron" nor "Bubble": - * - * testData.flag('flag') - * .ifNotMatch('user', 'name', 'Saffron', 'Bubble') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the user attribute to match against - * @param values values to compare to - * @return - * a flag rule builder; call `thenReturn` to finish the rule - * or add more tests with another method like `andNotMatch` - */ - ifNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder; -} - -/** - * A builder for feature flag rules to be used with [[TestDataFlagBuilder]]. - * - * In the LaunchDarkly model, a flag can have any number of rules, and - * a rule can have any number of clauses. A clause is an individual test - * such as "name is 'X'". A rule matches a user if all of the rule's - * clauses match the user. - * - * To start defining a rule, use one of the flag builder's matching methods - * such as `ifMatch`. This defines the first clause for the rule. Optionally, - * you may add more clauses with the rule builder's methods such as `andMatch`. - * Finally, call `thenReturn` to finish defining the rule. - */ - -export interface TestDataRuleBuilder { - /** - * Adds another clause using the "is one of" operator. - * - * For example, this creates a rule that returns `true` if the name is - * "Patsy" and the country is "gb": - * - * testData.flag('flag') - * .ifMatch('name', 'Patsy') - * .andMatch('country', 'gb') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the user attribute to match against - * @param values values to compare to - * @return the flag rule builder - */ - andMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder; - - /** - * Adds another clause using the "is not one of" operator. - * - * For example, this creates a rule that returns `true` if the name is - * "Patsy" and the country is not "gb": - * - * testData.flag('flag') - * .ifMatch('name', 'Patsy') - * .andNotMatch('country', 'gb') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the user attribute to match against - * @param values values to compare to - * @return the flag rule builder - */ - andNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder; - - /** - * Finishes defining the rule, specifying the result value as either a boolean or an index - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag rule builder - */ - thenReturn(variation: boolean | number): TestDataFlagBuilder; -} diff --git a/server-sdk-common/src/integrations/test_data/TestData.ts b/server-sdk-common/src/integrations/test_data/TestData.ts new file mode 100644 index 0000000000..cc081b9450 --- /dev/null +++ b/server-sdk-common/src/integrations/test_data/TestData.ts @@ -0,0 +1,172 @@ +import { LDStreamProcessor } from '../api'; +import { Flag } from '../evaluation/data/Flag'; +import { Segment } from '../evaluation/data/Segment'; +import Configuration from '../options/Configuration'; +import AsyncStoreFacade from '../store/AsyncStoreFacade'; +import { processFlag, processSegment } from '../store/serialization'; +import VersionedDataKinds from '../store/VersionedDataKinds'; +import TestDataFlagBuilder from './TestDataFlagBuilder'; +import { TestDataSource } from './TestDataSource'; + +/** + * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK + * client in test scenarios. + * + * Unlike [[FileData]], this mechanism does not use any external resources. It provides only the + * data that the application has put into it using the [[TestData.update]] method. + * + * @example + * TKTK: Needs updating when we know the package name. + * const { TestData } = require('launchdarkly-node-server-sdk/interfaces'); + * + * const td = TestData(); + * testData.update(td.flag("flag-key-1").booleanFlag().variationForAll(true)); + * const client = new LDClient(sdkKey, { updateProcessor: td.getFactory() }); + * + * // flags can be updated at any time: + * td.update(td.flag("flag-key-2") + * .variationForContext("user", "some-user-key", true) + * .fallthroughVariation(false)); + * + * The above example uses a simple boolean flag, but more complex configurations are possible using + * the methods of the [[TestDataFlagBuilder]] that is returned by [[TestData.flag]]. + * [[TestDataFlagBuilder]] supports many of the ways a flag can be configured on the LaunchDarkly + * dashboard, but does not currently support + * 1. rule operators other than "in" and "not in", or + * 2. percentage rollouts. + * + * If the same `TestData` instance is used to configure multiple `LDClient` instances, + * any changes made to the data will propagate to all of the `LDClient`s. + * + * @see [[FileDataSource]] + */ +export default class TestData { + private currentFlags: Record = {}; + + private currentSegments: Record = {}; + + private dataSources: TestDataSource[] = []; + + private flagBuilders: Record = {}; + + /** + * Get a factory for update processors that will be attached to this TestData instance. + * @returns An update processor factory. + */ + getFactory(): (config: Configuration) => LDStreamProcessor { + // Provides an arrow function to prevent needed to bind the method to + // maintain `this`. + return (config: Configuration) => new TestDataSource(new AsyncStoreFacade(config.featureStore), this.currentFlags, this.currentSegments, (tds) => { + this.dataSources.splice(this.dataSources.indexOf(tds)); + }); + } + + /** + * Creates or copies a [[TestDataFlagBuilder]] for building a test flag configuration. + * + * If the flag key has already been defined in this `TestData` instance, + * then the builder starts with the same configuration that was last + * provided for this flag. + * + * Otherwise, it starts with a new default configuration in which the flag + * has `true` and `false` variations, is `true` for all users when targeting + * is turned on and `false` otherwise, and currently has targeting turned on. + * You can change any of those properties and provide more complex behavior + * using the `TestDataFlagBuilder` methods. + * + * Once you have set the desired configuration, pass the builder to + * [[TestData.update]]. + * + * @param key the flag key + * @returns a flag configuration builder + * + */ + flag(key: string): TestDataFlagBuilder { + if (this.flagBuilders[key]) { + return this.flagBuilders[key].clone(); + } + return new TestDataFlagBuilder(key).booleanFlag(); + } + + /** + * Updates the test data with the specified flag configuration. + * + * This has the same effect as if a flag were added or modified in the + * LaunchDarkly dashboard. It immediately propagates the flag changes to + * any [[LDClient]] instance(s) that you have already configured to use + * this `TestData`. If no `LDClient` has been started yet, it simply adds + * this flag to the test data which will be provided to any `LDClient` + * that you subsequently configure. + * + * Any subsequent changes to this `TestDataFlagBuilder` instance do not affect + * the test data unless you call `update` again. + * + * @param flagBuilder a flag configuration builder + * @return a promise that will resolve when the feature stores are updated + */ + update(flagBuilder: TestDataFlagBuilder): Promise { + const flagKey = flagBuilder.getKey(); + const oldItem = this.currentFlags[flagKey]; + const oldVersion = oldItem ? oldItem.version : 0; + const newFlag = flagBuilder.build(oldVersion + 1); + this.currentFlags[flagKey] = newFlag; + this.flagBuilders[flagKey] = flagBuilder.clone(); + + return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Features, newFlag))); + } + + /** + * Copies a full feature flag data model object into the test data. + * + * It immediately propagates the flag change to any [[LDClient]] instance(s) that you have already + * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds this + * flag to the test data which will be provided to any LDClient that you subsequently configure. + * + * Use this method if you need to use advanced flag configuration properties that are not + * supported by the simplified [[TestDataFlagBuilder]] API. Otherwise it is recommended to use the + * regular [[flag]]/[[update]] mechanism to avoid dependencies on details of the data model. + * + * You cannot make incremental changes with [[flag]]/[[update]] to a flag that has been added in + * this way; you can only replace it with an entirely new flag configuration. + * + * @param flagConfig the flag configuration as a JSON object + * @return a promise that will resolve when the feature stores are updated + */ + usePreconfiguredFlag(inConfig: any): Promise { + // We need to do things like process attribute reference, and + // we do not want to modify the passed in value. + const flagConfig = JSON.parse(JSON.stringify(inConfig)); + processFlag(flagConfig); + const oldItem = this.currentFlags[flagConfig.key]; + const newItem = { ...flagConfig, version: oldItem ? oldItem.version + 1 : flagConfig.version }; + this.currentFlags[flagConfig.key] = newItem; + + return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Features, newItem))); + } + + /** + * Copies a full segment data model object into the test data. + * + * It immediately propagates the change to any [[LDClient]] instance(s) that you have already + * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds + * this segment to the test data which will be provided to any LDClient that you subsequently + * configure. + * + * This method is currently the only way to inject segment data, since there is no builder + * API for segments. It is mainly intended for the SDK's own tests of segment functionality, + * since application tests that need to produce a desired evaluation state could do so more easily + * by just setting flag values. + * + * @param segmentConfig the segment configuration as a JSON object + * @return a promise that will resolve when the feature stores are updated + */ + usePreconfiguredSegment(inConfig: any): Promise { + const segmentConfig = JSON.parse(JSON.stringify(inConfig)); + processSegment(segmentConfig); + const oldItem = this.currentSegments[segmentConfig.key]; + const newItem = { ...segmentConfig, version: oldItem ? oldItem.version + 1 : segmentConfig.version }; + this.currentSegments[segmentConfig.key] = newItem; + + return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Segments, newItem))); + } +} diff --git a/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts new file mode 100644 index 0000000000..af703f58dd --- /dev/null +++ b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts @@ -0,0 +1,405 @@ +import { TypeValidators } from '@launchdarkly/js-sdk-common'; +import { Flag } from '../evaluation/data/Flag'; +import TestDataRuleBuilder from './TestDataRuleBuilder'; +import { variationForBoolean } from './booleanVariation'; + +interface BuilderData { + on: boolean; + variations: any[]; + offVariation?: number; + fallthroughVariation?: number; + // For a given variation, what are the targets of that variation. + // Each target being a context kind and a list of keys for that kind. + targetsByVariation?: Record>; + rules?: TestDataRuleBuilder[]; +} + +/** + * A builder for feature flag configurations to be used with [[TestData]]. + */ +export default class TestDataFlagBuilder { + private data: BuilderData = { + on: true, + variations: [], + }; + + /** + * @internal + */ + constructor(private readonly key: string, data?: BuilderData) { + if (data) { + // Not the fastest way to deep copy, but this is a testing mechanism. + this.data = { + on: data.on, + variations: [...data.variations], + } + if (data.offVariation !== undefined) { + this.data.offVariation = data.offVariation; + } + if (data.fallthroughVariation !== undefined) { + this.data.offVariation = data.offVariation; + } + if (data.targetsByVariation) { + this.data.targetsByVariation = JSON.parse(JSON.stringify(data.targetsByVariation)); + } + if (data.rules) { + this.data.rules = []; + data.rules.forEach((rule) => { + this.data.rules?.push(rule.clone()) + }); + } + } + } + + private get isBooleanFlag(): boolean { + return this.data.variations.length === 2 + && this.data.variations[TRUE_VARIATION_INDEX] === true + && this.data.variations[FALSE_VARIATION_INDEX] === false; + } + + /** + * A shortcut for setting the flag to use the standard boolean configuration. + * + * This is the default for all new flags created with [[TestData.flag]]. The + * flag will have two variations, `true` and `false` (in that order). It + * will return `false` whenever targeting is off and `true` when targeting + * is on unless other settings specify otherwise. + * + * @return the flag builder + */ + booleanFlag(): TestDataFlagBuilder { + if (this.isBooleanFlag) { + return this; + } + // Change this flag into a boolean flag. + return this.variations(true, false) + .fallthroughVariation(TRUE_VARIATION_INDEX) + .offVariation(FALSE_VARIATION_INDEX) + } + + /** + * Sets the allowable variation values for the flag. + * + * The values may be of any JSON-compatible type: boolean, number, string, array, + * or object. For instance, a boolean flag normally has `variations(true, false)`; + * a string-valued flag might have `variations("red", "green")`; etc. + * + * @param values any number of variation values + * @return the flag builder + */ + variations(...values: any[]): TestDataFlagBuilder { + this.data.variations = [...values]; + return this; + } + + /** + * Sets targeting to be on or off for this flag. + * + * The effect of this depends on the rest of the flag configuration, just + * as it does on the real LaunchDarkly dashboard. In the default configuration + * that you get from calling [[TestData.flag]] with a new flag key, the flag + * will return `false` whenever targeting is off and `true` when targeting + * is on. + * + * @param targetingOn true if targeting should be on + * @return the flag builder + */ + on(targetingOn: boolean): TestDataFlagBuilder { + this.data.on = targetingOn; + return this; + } + + /** + * Specifies the fallthrough variation for a flag. The fallthrough is + * the value that is returned if targeting is on and the user was not + * matched by a more specific target or rule. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param variation + * either `true` or `false` or the index of the desired fallthrough + * variation: 0 for the first, 1 for the second, etc. + * @return the flag builder + */ + fallthroughVariation(variation: number | boolean): TestDataFlagBuilder { + if (TypeValidators.Boolean.is(variation)) { + return this.booleanFlag().fallthroughVariation(variationForBoolean(variation)); + } + this.data.fallthroughVariation = variation; + return this; + } + + /** + * Specifies the off variation for a flag. This is the variation that is + * returned whenever targeting is off. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param variation + * either `true` or `false` or the index of the desired off + * variation: 0 for the first, 1 for the second, etc. + * @return the flag builder + */ + offVariation(variation: number | boolean): TestDataFlagBuilder { + if (TypeValidators.Boolean.is(variation)) { + return this.booleanFlag().offVariation(variationForBoolean(variation)); + } + this.data.offVariation = variation; + return this; + } + + /** + * Sets the flag to always return the specified variation for all contexts. + * + * Targeting is switched on, any existing targets or rules are removed, + * and the fallthrough variation is set to the specified value. The off + * variation is left unchanged. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param varation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ + variationForAll(variation: number | boolean): TestDataFlagBuilder { + return this.on(true).clearRules().clearAllTargets().fallthroughVariation(variation); + } + + /** + * Sets the flag to always return the specified variation value for all contexts. + * + * The value may be of any valid JSON type. This method changes the flag to have + * only a single variation, which is this value, and to return the same variation + * regardless of whether targeting is on or off. Any existing targets or rules + * are removed. + * + * @param value The desired value to be returned for all contexts. + * @return the flag builder + */ + valueForAll(value: any): TestDataFlagBuilder { + return this.variations(value).variationForAll(0); + } + + /** + * Sets the flag to return the specified variation for a specific context key + * when targeting is on. The context kind for contexts created with this method + * will be 'user'. + * + * This has no effect when targeting is turned off for the flag. + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + * + * @param contextKey a context key + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ + variationForUser(contextKey: string, variation: number | boolean): TestDataFlagBuilder { + return this.variationForContext('user', contextKey, variation); + } + + /** + * Sets the flag to return the specified variation for a specific context key + * when targeting is on. + * + * This has no effect when targeting is turned off for the flag. + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + * + * @param contextKind a context kind + * @param contextKey a context key + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ + variationForContext(contextKind: string, contextKey: string, variation: number | boolean): TestDataFlagBuilder { + if (TypeValidators.Boolean.is(variation)) { + return this.booleanFlag().variationForContext(contextKind, contextKey, variationForBoolean(variation)); + } + + if (!this.data.targetsByVariation) { + this.data.targetsByVariation = {}; + } + + this.data.variations.forEach((_, i) => { + if (i === variation) { + //If there is nothing set at the current variation then set it to the empty array + const targetsForVariation = this.data.targetsByVariation![i] || {}; + + if (!(contextKind in targetsForVariation)) { + targetsForVariation[contextKind] = []; + } + const exists = targetsForVariation[contextKind].indexOf(contextKey) !== -1; + // Add context to current variation set if they aren't already there + if (!exists) { + targetsForVariation[contextKind].push(contextKey); + } + + this.data.targetsByVariation![i] = targetsForVariation; + } else { + // remove user from other variation set if necessary + const targetsForVariation = this.data.targetsByVariation![i]; + if (targetsForVariation) { + const targetsForContextKind = targetsForVariation[contextKind]; + if (targetsForContextKind) { + const targetIndex = targetsForContextKind.indexOf(contextKey); + if (targetIndex !== -1) { + targetsForContextKind.splice(targetIndex, 1); + if (!targetsForContextKind.length) { + delete targetsForVariation[contextKind]; + } + } + } + if (!targetsForVariation.size) { + delete this.data.targetsByVariation![i]; + } + } + } + }); + + return this; + } + + /** + * Removes any existing rules from the flag. This undoes the effect of methods + * like [[ifMatch]]. + * + * @return the same flag builder + */ + clearRules(): TestDataFlagBuilder { + delete this.data.rules; + return this; + } + + /** + * Removes any existing targets from the flag. This undoes the effect of + * methods like [[variationForContext]]. + * + * @return the same flag builder + */ + clearAllTargets(): TestDataFlagBuilder { + delete this.data.targetsByVariation; + return this; + } + + /** + * Starts defining a flag rule using the "is one of" operator. + * + * For example, this creates a rule that returnes `true` if the name is + * "Patsy" or "Edina": + * + * testData.flag('flag') + * .ifMatch('user', name', 'Patsy', 'Edina') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the context attribute to match against + * @param values values to compare to + * @return + * a flag rule builder; call `thenReturn` to finish the rule + * or add more tests with another method like `andMatch` + */ + ifMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + const flagRuleBuilder = new TestDataRuleBuilder(this); + return flagRuleBuilder.andMatch(contextKind, attribute, ...values); + } + + /** + * Starts defining a flag rule using the "is not one of" operator. + * + * For example, this creates a rule that returnes `true` if the name is + * neither "Saffron" nor "Bubble": + * + * testData.flag('flag') + * .ifNotMatch('user', 'name', 'Saffron', 'Bubble') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the user attribute to match against + * @param values values to compare to + * @return + * a flag rule builder; call `thenReturn` to finish the rule + * or add more tests with another method like `andNotMatch` + */ + ifNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + const flagRuleBuilder = new TestDataRuleBuilderImpl(this); + return flagRuleBuilder.andNotMatch(contextKind, attribute, ...values); + } + + /** + * @internal + */ + addRule(flagRuleBuilder: TestDataRuleBuilder) { + if (!this.data.rules) { + this.data.rules = []; + } + this.data.rules.push(flagRuleBuilder as TestDataRuleBuilderImpl); + }; + + /** + * @internal + */ + build(version: number) { + const baseFlagObject: Flag = { + key: this.key, + version: version, + on: this.data.on, + offVariation: this.data.offVariation, + fallthrough: { + variation: this.data.fallthroughVariation, + }, + variations: [...this.data.variations], + }; + + if (this.data.targetsByVariation) { + const contextTargets = []; + for (const [variation, contextTargetsForVariation] of Object.entries(this.data.targetsByVariation)) { + for (const [contextKind, values] of Object.entries(contextTargetsForVariation)) { + contextTargets.push({ + contextKind, + values, + // Iterating the object it will be a string. + variation: parseInt(variation, 10), + }); + } + } + baseFlagObject.contextTargets = contextTargets; + } + + if (this.data.rules) { + baseFlagObject.rules = this.data.rules.map((rule, i) => + (rule as TestDataRuleBuilderImpl).build(String(i)) + ); + } + + return baseFlagObject; + }; + + /** + * @internal + */ + clone(): TestDataFlagBuilderImpl { + return new TestDataFlagBuilderImpl(this.key, this.data); + } + + /** + * @internal + */ + getKey(): string { + return this.key; + } +} diff --git a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts new file mode 100644 index 0000000000..2754f59e39 --- /dev/null +++ b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts @@ -0,0 +1,138 @@ +import { AttributeReference, TypeValidators } from '@launchdarkly/js-sdk-common'; +import { Clause } from '../evaluation/data/Clause'; +import TestDataFlagBuilder from './TestDataFlagBuilder'; +import { variationForBoolean } from './booleanVariation'; + +/** + * A builder for feature flag rules to be used with [[TestDataFlagBuilder]]. + * + * In the LaunchDarkly model, a flag can have any number of rules, and + * a rule can have any number of clauses. A clause is an individual test + * such as "name is 'X'". A rule matches a user if all of the rule's + * clauses match the user. + * + * To start defining a rule, use one of the flag builder's matching methods + * such as `ifMatch`. This defines the first clause for the rule. Optionally, + * you may add more clauses with the rule builder's methods such as `andMatch`. + * Finally, call `thenReturn` to finish defining the rule. + */ + +export default class TestDataRuleBuilder { + private clauses: Clause[] = []; + + private variation?: number; + + /** + * @internal + */ + constructor( + private readonly flagBuilder: TestDataFlagBuilder, + clauses?: Clause[], + variation?: number) { + if (clauses) { + this.clauses = JSON.parse(JSON.stringify(clauses)); + } + if (variation !== undefined) { + this.variation = variation; + } + } + + /** + * Adds another clause using the "is one of" operator. + * + * For example, this creates a rule that returns `true` if the name is + * "Patsy" and the country is "gb": + * + * testData.flag('flag') + * .ifMatch('name', 'Patsy') + * .andMatch('country', 'gb') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the flag rule builder + */ + andMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + this.clauses.push({ + contextKind, + attribute: attribute, + attributeReference: new AttributeReference(attribute), + op: 'in', + values: values, + negate: false, + }); + return this; + } + + /** + * Adds another clause using the "is not one of" operator. + * + * For example, this creates a rule that returns `true` if the name is + * "Patsy" and the country is not "gb": + * + * testData.flag('flag') + * .ifMatch('name', 'Patsy') + * .andNotMatch('country', 'gb') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the flag rule builder + */ + andNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + this.clauses.push({ + contextKind, + attribute: attribute, + attributeReference: new AttributeReference(attribute), + op: 'in', + values: values, + negate: true, + }); + return this; + } + + /** + * Finishes defining the rule, specifying the result value as either a boolean or an index + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag rule builder + */ + thenReturn(variation: number | boolean): TestDataFlagBuilder { + if (TypeValidators.Boolean.is(variation)) { + this.flagBuilder.booleanFlag(); + return this.thenReturn(variationForBoolean(variation)); + } + + this.variation = variation; + this.flagBuilder.addRule(this); + return this.flagBuilder; + } + + /** + * @internal + */ + build(id: string) { + return { + id: 'rule' + id, + variation: this.variation, + clauses: this.clauses, + }; + }; + + /** + * @internal + */ + clone(): TestDataRuleBuilder { + return new TestDataRuleBuilder(this.flagBuilder, this.clauses, this.variation); + } +} diff --git a/server-sdk-common/src/integrations/test_data/TestDataSource.ts b/server-sdk-common/src/integrations/test_data/TestDataSource.ts new file mode 100644 index 0000000000..43c38fa628 --- /dev/null +++ b/server-sdk-common/src/integrations/test_data/TestDataSource.ts @@ -0,0 +1,38 @@ +import { LDStreamProcessor } from '../api'; +import { DataKind } from '../api/interfaces'; +import { LDKeyedFeatureStoreItem } from '../api/subsystems'; +import { Flag } from '../evaluation/data/Flag'; +import { Segment } from '../evaluation/data/Segment'; +import AsyncStoreFacade from '../store/AsyncStoreFacade'; +import VersionedDataKinds from '../store/VersionedDataKinds'; + +/** + * @internal + */ +export class TestDataSource implements LDStreamProcessor { + constructor( + private readonly featureStore: AsyncStoreFacade, + private readonly flags: Record, + private readonly segments: Record, + private readonly onStop: (tfs: TestDataSource) => void) { + } + async start(fn?: ((err?: any) => void) | undefined) { + await this.featureStore.init({ + [VersionedDataKinds.Features.namespace]: { ...this.flags }, + [VersionedDataKinds.Segments.namespace]: { ...this.segments }, + }); + fn?.(); + } + + stop() { + this.onStop(this); + } + + close() { + this.stop(); + } + + async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { + this.featureStore.upsert(kind, value); + } +} diff --git a/server-sdk-common/src/integrations/test_data/booleanVariation.ts b/server-sdk-common/src/integrations/test_data/booleanVariation.ts new file mode 100644 index 0000000000..26d6ce5819 --- /dev/null +++ b/server-sdk-common/src/integrations/test_data/booleanVariation.ts @@ -0,0 +1,6 @@ +export const TRUE_VARIATION_INDEX = 0; +export const FALSE_VARIATION_INDEX = 1; + +export function variationForBoolean(val: boolean) { + return val ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX; +} diff --git a/server-sdk-common/src/options/Configuration.ts b/server-sdk-common/src/options/Configuration.ts index be211480a4..cc037c7634 100644 --- a/server-sdk-common/src/options/Configuration.ts +++ b/server-sdk-common/src/options/Configuration.ts @@ -215,13 +215,6 @@ export default class Configuration { ); this.eventsCapacity = validatedOptions.capacity; this.timeout = validatedOptions.timeout; - if (TypeValidators.Function.is(validatedOptions.featureStore)) { - // @ts-ignore - this.featureStore = validatedOptions.featureStore(options); - } else { - // @ts-ignore - this.featureStore = validatedOptions.featureStore; - } // TODO: bigSegments this.updateProcessor = validatedOptions.updateProcessor; @@ -244,5 +237,13 @@ export default class Configuration { this.wrapperVersion = validatedOptions.wrapperVersion; this.tags = new ApplicationTags(validatedOptions); this.diagnosticRecordingInterval = validatedOptions.diagnosticRecordingInterval; + + if (TypeValidators.Function.is(validatedOptions.featureStore)) { + // @ts-ignore + this.featureStore = validatedOptions.featureStore(this); + } else { + // @ts-ignore + this.featureStore = validatedOptions.featureStore; + } } } diff --git a/server-sdk-common/src/store/serialization.ts b/server-sdk-common/src/store/serialization.ts index 1021f3b582..641b0581e5 100644 --- a/server-sdk-common/src/store/serialization.ts +++ b/server-sdk-common/src/store/serialization.ts @@ -68,7 +68,10 @@ function processRollout(rollout?: Rollout) { } } -function processFlag(flag: Flag) { +/** + * @internal + */ +export function processFlag(flag: Flag) { if (flag.fallthrough && flag.fallthrough.rollout) { const rollout = flag.fallthrough.rollout!; processRollout(rollout); @@ -86,7 +89,10 @@ function processFlag(flag: Flag) { }); } -function processSegment(segment: Segment) { +/** + * @internal + */ +export function processSegment(segment: Segment) { segment?.rules?.forEach((rule) => { if (rule.bucketBy) { // Rules before U2C would have had literals for attributes. From fe25e4f97e08a218eeab7f7c37045574b58055b1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Jul 2022 15:37:21 -0700 Subject: [PATCH 2/6] Finish implementing tests. --- .../integrations/test_data/TestData.test.ts | 395 +++++++++++++++++- .../src/api/integrations/index.ts | 1 - .../src/integrations/test_data/TestData.ts | 55 ++- .../test_data/TestDataFlagBuilder.ts | 72 ++-- .../test_data/TestDataRuleBuilder.ts | 43 +- .../integrations/test_data/TestDataSource.ts | 22 +- 6 files changed, 512 insertions(+), 76 deletions(-) diff --git a/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts index 7d76ce3e73..227b5c582c 100644 --- a/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts +++ b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts @@ -1,11 +1,398 @@ -import { LDClientImpl } from '../../../src'; +import { AttributeReference, LDClientImpl } from '../../../src'; +import { Flag } from '../../../src/evaluation/data/Flag'; +import { FlagRule } from '../../../src/evaluation/data/FlagRule'; import TestData from '../../../src/integrations/test_data/TestData'; +import Configuration from '../../../src/options/Configuration'; +import AsyncStoreFacade from '../../../src/store/AsyncStoreFacade'; import InMemoryFeatureStore from '../../../src/store/InMemoryFeatureStore'; +import VersionedDataKinds from '../../../src/store/VersionedDataKinds'; -it('initializes the datastore with flags configured before the client is started', () => { +const basicBooleanFlag: Flag = { + fallthrough: { + variation: 0, + }, + key: 'new-flag', + offVariation: 1, + on: true, + variations: [true, false], + version: 1, +}; + +it('initializes the data store with flags configured the data store is created', async () => { const td = new TestData(); td.update(td.flag('new-flag').variationForAll(true)); const store = new InMemoryFeatureStore(); - // const client = new LDClientImpl('sdk_key', { offline: true, featureStore: store, updateProcessor: td }); -}); \ No newline at end of file + const processor = td.getFactory()(new Configuration({ + featureStore: store, + })); + + processor.start(); + const facade = new AsyncStoreFacade(store); + + const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); + + expect(res).toEqual(basicBooleanFlag); +}); + +it('updates the data store when update is called', async () => { + const td = new TestData(); + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()(new Configuration({ + featureStore: store, + })); + + processor.start(); + const facade = new AsyncStoreFacade(store); + + // In this test the update is after initialization. + await td.update(td.flag('new-flag').variationForAll(true)); + const res = await facade.get(VersionedDataKinds.Features, 'new-flag'); + expect(res).toEqual(basicBooleanFlag); +}); + +it('can include pre-configured items', async () => { + const td = new TestData(); + td.usePreconfiguredFlag({ key: 'my-flag', version: 1000, on: true }); + td.usePreconfiguredSegment({ key: 'my-segment', version: 2000 }); + + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()(new Configuration({ + featureStore: store, + })); + + processor.start(); + + td.usePreconfiguredFlag({ key: 'my-flag', on: false }); + td.usePreconfiguredFlag({ key: 'my-flag-2', version: 1000, on: true }); + td.usePreconfiguredSegment({ key: 'my-segment', included: ['x'] }); + td.usePreconfiguredSegment({ key: 'my-segment-2', version: 2000 }); + + const facade = new AsyncStoreFacade(store); + const allFlags = await facade.all(VersionedDataKinds.Features); + const allSegments = await facade.all(VersionedDataKinds.Segments); + + expect(allFlags).toEqual({ + 'my-flag': { + key: 'my-flag', + on: false, + version: 1001, + }, + 'my-flag-2': { + key: 'my-flag-2', + on: true, + version: 1000, + }, + }); + + expect(allSegments).toEqual({ + 'my-segment': { + included: [ + 'x', + ], + key: 'my-segment', + version: 2001, + }, + 'my-segment-2': { + key: 'my-segment-2', + version: 2000, + }, + }); +}); + +it.each([true, false])('does not update the store after stop/close is called', async (stop) => { + const td = new TestData(); + + const store = new InMemoryFeatureStore(); + const processor = td.getFactory()(new Configuration({ + featureStore: store, + })); + + processor.start(); + td.update(td.flag('new-flag').variationForAll(true)); + if (stop) { + processor.stop(); + } else { + processor.close(); + } + td.update(td.flag('new-flag-2').variationForAll(true)); + + const facade = new AsyncStoreFacade(store); + + const flag1 = await facade.get(VersionedDataKinds.Features, 'new-flag'); + const flag2 = await facade.get(VersionedDataKinds.Features, 'new-flag-2'); + + expect(flag1).toBeDefined(); + expect(flag2).toBeNull(); +}); + +it('can update a flag that already exists in the store', async () => { + const td = new TestData(); + + const store = new InMemoryFeatureStore(); + + const processor = td.getFactory()(new Configuration({ + featureStore: store, + })); + + processor.start(); + td.update(td.flag('new-flag').variationForAll(true)); + td.update(td.flag('new-flag').variationForAll(false)); + + const facade = new AsyncStoreFacade(store); + const res = (await facade.get(VersionedDataKinds.Features, 'new-flag')) as Flag; + expect(res.version).toEqual(2); + expect(res.fallthrough.variation).toEqual(1); +}); + +describe('given a TestData instance', () => { + let td: TestData; + beforeEach(() => { + td = new TestData(); + }); + + it('doesn\'t provide the same reference when updating an existing builder', () => { + const flag = td.flag('test-flag'); + td.update(flag); + const flagCopy = td.flag('test-flag'); + flagCopy.on(false); + expect(flagCopy).not.toEqual(flag); + }); + + it('can clone a complex flag configuration', () => { + const flag = td.flag('test-flag') + .ifMatch('user', 'name', 'ben', 'christian') + .andNotMatch('user', 'country', 'fr') + .thenReturn(true); + + td.update(flag); + const flagCopy = td.flag('test-flag'); + + const flagRules: FlagRule[] = [{ + id: 'rule0', + variation: 0, + clauses: [ + { + attribute: 'name', + attributeReference: new AttributeReference('name'), + contextKind: 'user', + negate: false, + op: 'in', + values: [ + 'ben', + 'christian', + ], + }, + { + contextKind: 'user', + attribute: 'country', + attributeReference: new AttributeReference('country'), + negate: true, + op: 'in', + values: [ + 'fr', + ], + }, + ], + }]; + + expect(flagCopy.build(1).rules).toEqual(flagRules); + }); + + it('defaults a new flag to on', () => { + expect(td.flag('whatever').build(0).on).toBe(true); + }); + + it('defaults a new flag builder to a boolean flag', () => { + const flag = td.flag('test-flag-booleanFlags').build(1); + flag.variations.every((val) => expect(typeof val).toBe('boolean')); + expect(flag.variations[1]).not.toEqual(flag.variations[0]); + expect(flag.variations.length).toBe(2); + }); + + it('can set variations on the flag builder', () => { + const flag = td.flag('test-flag'); + flag.variations('a', 'b'); + expect(flag.build(0).variations).toEqual(['a', 'b']); + }); + + it('can set a value for all', () => { + const flag = td.flag('test-flag'); + flag.valueForAll('potato'); + const built = flag.build(1); + expect(built.variations).toEqual(['potato']); + expect(built.fallthrough.variation).toEqual(0); + }); + + it('can handle boolean values for *Variation setters', () => { + const flag = td.flag('test-flag').fallthroughVariation(false); + expect(flag.build(0).fallthrough).toEqual({ variation: 1 }); + + const offFlag = td.flag('off-flag').offVariation(true); + expect(offFlag.build(0).fallthrough).toEqual({ variation: 0 }); + }); + + it('can set boolean values for a specific user target', () => { + const flag = td.flag('test-flag').variationForContext('user', 'potato', false); + const flag2 = td.flag('test-flag').variationForUser('potato', true); + expect(flag.build(0).contextTargets).toEqual([ + { + contextKind: 'user', + variation: 1, + values: ['potato'], + }, + ]); + expect(flag2.build(0).contextTargets).toEqual([ + { + contextKind: 'user', + variation: 0, + values: ['potato'], + }, + ]); + }); + + it('can clear targets', () => { + const flag = td.flag('test-flag').variationForContext('user', 'potato', false); + const clearedFlag = flag.clone().clearAllTargets(); + expect(clearedFlag.build(0)).not.toHaveProperty('targets'); + }); + + it('can make not matching rules', () => { + const flag = td.flag('flag') + .ifNotMatch('user', 'name', 'Saffron', 'Bubble') + .thenReturn(true); + + expect(flag.build(1)).toEqual({ + fallthrough: { + variation: 0, + }, + key: 'flag', + offVariation: 1, + on: true, + rules: [ + { + clauses: [ + { + attribute: 'name', + attributeReference: { + components: [ + 'name', + ], + isValid: true, + redactionName: 'name', + }, + contextKind: 'user', + negate: true, + op: 'in', + values: [ + 'Saffron', + 'Bubble', + ], + }, + ], + id: 'rule0', + variation: 0, + }, + ], + variations: [ + true, + false, + ], + version: 1, + }); + }); + + it('can add and remove a rule', () => { + const flag = td.flag('test-flag') + .ifMatch('user', 'name', 'ben', 'christian') + .andNotMatch('user', 'country', 'fr') + .thenReturn(true); + + const flagRules: FlagRule[] = [{ + id: 'rule0', + variation: 0, + clauses: [ + { + attribute: 'name', + attributeReference: new AttributeReference('name'), + contextKind: 'user', + negate: false, + op: 'in', + values: [ + 'ben', + 'christian', + ], + }, + { + contextKind: 'user', + attribute: 'country', + attributeReference: new AttributeReference('country'), + negate: true, + op: 'in', + values: [ + 'fr', + ], + }, + ], + }]; + + expect(flag.build(1).rules).toEqual(flagRules); + + const clearedRulesFlag = flag.clearRules(); + expect(clearedRulesFlag.build(0)).not.toHaveProperty('rules'); + }); + + it('can move a targeted context from one variation to another', () => { + const flag = td.flag('test-flag').variationForContext('user', 'ben', false).variationForContext('user', 'ben', true); + // Because there was only one target in the first variation there will be only + // a single variation after that target is removed. + expect(flag.build(1).contextTargets).toEqual([ + { + contextKind: 'user', + variation: 0, + values: ['ben'], + }, + ]); + }); + + it('if a targeted context is moved from one variation to another, then other targets remain for that variation', () => { + const flag = td.flag('test-flag') + .variationForContext('user', 'ben', false) + .variationForContext('user', 'joe', false) + .variationForContext('user', 'ben', true); + + expect(flag.build(1).contextTargets).toEqual([ + { + contextKind: 'user', + variation: 0, + values: ['ben'], + }, + { + contextKind: 'user', + variation: 1, + values: ['joe'], + }, + ]); + }); + + it('should allow targets from multiple contexts in the same variation', () => { + const flag = td.flag('test-flag') + .variationForContext('user', 'ben', false) + .variationForContext('potato', 'russet', false) + .variationForContext('potato', 'yukon', false); + // Because there was only one target in the first variation there will be only + // a single variation after that target is removed. + expect(flag.build(0).contextTargets).toEqual([ + { + contextKind: 'user', + variation: 1, + values: ['ben'], + }, + { + contextKind: 'potato', + variation: 1, + values: ['russet', 'yukon'], + }, + ]); + }); +}); diff --git a/server-sdk-common/src/api/integrations/index.ts b/server-sdk-common/src/api/integrations/index.ts index cff538a563..fdf3c3574d 100644 --- a/server-sdk-common/src/api/integrations/index.ts +++ b/server-sdk-common/src/api/integrations/index.ts @@ -1,2 +1 @@ export * from './FileDataSourceOptions'; -export * from './TestData'; diff --git a/server-sdk-common/src/integrations/test_data/TestData.ts b/server-sdk-common/src/integrations/test_data/TestData.ts index cc081b9450..e4356f288c 100644 --- a/server-sdk-common/src/integrations/test_data/TestData.ts +++ b/server-sdk-common/src/integrations/test_data/TestData.ts @@ -1,12 +1,12 @@ -import { LDStreamProcessor } from '../api'; -import { Flag } from '../evaluation/data/Flag'; -import { Segment } from '../evaluation/data/Segment'; -import Configuration from '../options/Configuration'; -import AsyncStoreFacade from '../store/AsyncStoreFacade'; -import { processFlag, processSegment } from '../store/serialization'; -import VersionedDataKinds from '../store/VersionedDataKinds'; +import { LDStreamProcessor } from '../../api'; +import { Flag } from '../../evaluation/data/Flag'; +import { Segment } from '../../evaluation/data/Segment'; +import Configuration from '../../options/Configuration'; +import AsyncStoreFacade from '../../store/AsyncStoreFacade'; +import { processFlag, processSegment } from '../../store/serialization'; +import VersionedDataKinds from '../../store/VersionedDataKinds'; import TestDataFlagBuilder from './TestDataFlagBuilder'; -import { TestDataSource } from './TestDataSource'; +import TestDataSource from './TestDataSource'; /** * A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK @@ -56,9 +56,20 @@ export default class TestData { getFactory(): (config: Configuration) => LDStreamProcessor { // Provides an arrow function to prevent needed to bind the method to // maintain `this`. - return (config: Configuration) => new TestDataSource(new AsyncStoreFacade(config.featureStore), this.currentFlags, this.currentSegments, (tds) => { - this.dataSources.splice(this.dataSources.indexOf(tds)); - }); + return (config: Configuration) => { + const newSource = new TestDataSource( + new AsyncStoreFacade(config.featureStore), + this.currentFlags, + + this.currentSegments, + (tds) => { + this.dataSources.splice(this.dataSources.indexOf(tds)); + }, + ); + + this.dataSources.push(newSource); + return newSource; + }; } /** @@ -112,7 +123,9 @@ export default class TestData { this.currentFlags[flagKey] = newFlag; this.flagBuilders[flagKey] = flagBuilder.clone(); - return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Features, newFlag))); + return Promise.all( + this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newFlag)), + ); } /** @@ -136,12 +149,14 @@ export default class TestData { // We need to do things like process attribute reference, and // we do not want to modify the passed in value. const flagConfig = JSON.parse(JSON.stringify(inConfig)); - processFlag(flagConfig); const oldItem = this.currentFlags[flagConfig.key]; const newItem = { ...flagConfig, version: oldItem ? oldItem.version + 1 : flagConfig.version }; + processFlag(newItem); this.currentFlags[flagConfig.key] = newItem; - return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Features, newItem))); + return Promise.all( + this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Features, newItem)), + ); } /** @@ -162,11 +177,17 @@ export default class TestData { */ usePreconfiguredSegment(inConfig: any): Promise { const segmentConfig = JSON.parse(JSON.stringify(inConfig)); - processSegment(segmentConfig); + const oldItem = this.currentSegments[segmentConfig.key]; - const newItem = { ...segmentConfig, version: oldItem ? oldItem.version + 1 : segmentConfig.version }; + const newItem = { + ...segmentConfig, + version: oldItem ? oldItem.version + 1 : segmentConfig.version, + }; + processFlag(newItem); this.currentSegments[segmentConfig.key] = newItem; - return Promise.all(this.dataSources.map(impl => impl.upsert(VersionedDataKinds.Segments, newItem))); + return Promise.all( + this.dataSources.map((impl) => impl.upsert(VersionedDataKinds.Segments, newItem)), + ); } } diff --git a/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts index af703f58dd..981ff779e5 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts @@ -1,7 +1,8 @@ import { TypeValidators } from '@launchdarkly/js-sdk-common'; -import { Flag } from '../evaluation/data/Flag'; +import { Flag } from '../../evaluation/data/Flag'; +import { Target } from '../../evaluation/data/Target'; +import { TRUE_VARIATION_INDEX, FALSE_VARIATION_INDEX, variationForBoolean } from './booleanVariation'; import TestDataRuleBuilder from './TestDataRuleBuilder'; -import { variationForBoolean } from './booleanVariation'; interface BuilderData { on: boolean; @@ -11,7 +12,7 @@ interface BuilderData { // For a given variation, what are the targets of that variation. // Each target being a context kind and a list of keys for that kind. targetsByVariation?: Record>; - rules?: TestDataRuleBuilder[]; + rules?: TestDataRuleBuilder[]; } /** @@ -32,7 +33,7 @@ export default class TestDataFlagBuilder { this.data = { on: data.on, variations: [...data.variations], - } + }; if (data.offVariation !== undefined) { this.data.offVariation = data.offVariation; } @@ -45,7 +46,7 @@ export default class TestDataFlagBuilder { if (data.rules) { this.data.rules = []; data.rules.forEach((rule) => { - this.data.rules?.push(rule.clone()) + this.data.rules?.push(rule.clone()); }); } } @@ -74,7 +75,7 @@ export default class TestDataFlagBuilder { // Change this flag into a boolean flag. return this.variations(true, false) .fallthroughVariation(TRUE_VARIATION_INDEX) - .offVariation(FALSE_VARIATION_INDEX) + .offVariation(FALSE_VARIATION_INDEX); } /** @@ -226,9 +227,14 @@ export default class TestDataFlagBuilder { * 0 for the first, 1 for the second, etc. * @return the flag builder */ - variationForContext(contextKind: string, contextKey: string, variation: number | boolean): TestDataFlagBuilder { + variationForContext( + contextKind: string, + contextKey: string, + variation: number | boolean, + ): TestDataFlagBuilder { if (TypeValidators.Boolean.is(variation)) { - return this.booleanFlag().variationForContext(contextKind, contextKey, variationForBoolean(variation)); + return this.booleanFlag() + .variationForContext(contextKind, contextKey, variationForBoolean(variation)); } if (!this.data.targetsByVariation) { @@ -237,7 +243,7 @@ export default class TestDataFlagBuilder { this.data.variations.forEach((_, i) => { if (i === variation) { - //If there is nothing set at the current variation then set it to the empty array + // If there is nothing set at the current variation then set it to the empty array const targetsForVariation = this.data.targetsByVariation![i] || {}; if (!(contextKind in targetsForVariation)) { @@ -264,7 +270,7 @@ export default class TestDataFlagBuilder { } } } - if (!targetsForVariation.size) { + if (!Object.keys(targetsForVariation).length) { delete this.data.targetsByVariation![i]; } } @@ -313,7 +319,11 @@ export default class TestDataFlagBuilder { * a flag rule builder; call `thenReturn` to finish the rule * or add more tests with another method like `andMatch` */ - ifMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + ifMatch( + contextKind: string, + attribute: string, + ...values: any + ): TestDataRuleBuilder { const flagRuleBuilder = new TestDataRuleBuilder(this); return flagRuleBuilder.andMatch(contextKind, attribute, ...values); } @@ -321,7 +331,7 @@ export default class TestDataFlagBuilder { /** * Starts defining a flag rule using the "is not one of" operator. * - * For example, this creates a rule that returnes `true` if the name is + * For example, this creates a rule that returns `true` if the name is * neither "Saffron" nor "Bubble": * * testData.flag('flag') @@ -335,20 +345,24 @@ export default class TestDataFlagBuilder { * a flag rule builder; call `thenReturn` to finish the rule * or add more tests with another method like `andNotMatch` */ - ifNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { - const flagRuleBuilder = new TestDataRuleBuilderImpl(this); + ifNotMatch( + contextKind: string, + attribute: string, + ...values: any + ): TestDataRuleBuilder { + const flagRuleBuilder = new TestDataRuleBuilder(this); return flagRuleBuilder.andNotMatch(contextKind, attribute, ...values); } /** * @internal */ - addRule(flagRuleBuilder: TestDataRuleBuilder) { + addRule(flagRuleBuilder: TestDataRuleBuilder) { if (!this.data.rules) { this.data.rules = []; } - this.data.rules.push(flagRuleBuilder as TestDataRuleBuilderImpl); - }; + this.data.rules.push(flagRuleBuilder as TestDataRuleBuilder); + } /** * @internal @@ -356,7 +370,7 @@ export default class TestDataFlagBuilder { build(version: number) { const baseFlagObject: Flag = { key: this.key, - version: version, + version, on: this.data.on, offVariation: this.data.offVariation, fallthrough: { @@ -366,34 +380,36 @@ export default class TestDataFlagBuilder { }; if (this.data.targetsByVariation) { - const contextTargets = []; - for (const [variation, contextTargetsForVariation] of Object.entries(this.data.targetsByVariation)) { - for (const [contextKind, values] of Object.entries(contextTargetsForVariation)) { + const contextTargets: Target[] = []; + Object.entries( + this.data.targetsByVariation, + ).forEach(([variation, contextTargetsForVariation]) => { + Object.entries(contextTargetsForVariation).forEach(([contextKind, values]) => { contextTargets.push({ contextKind, values, // Iterating the object it will be a string. variation: parseInt(variation, 10), }); - } - } + }); + }); baseFlagObject.contextTargets = contextTargets; } if (this.data.rules) { - baseFlagObject.rules = this.data.rules.map((rule, i) => - (rule as TestDataRuleBuilderImpl).build(String(i)) + baseFlagObject.rules = this.data.rules.map( + (rule, i) => (rule as TestDataRuleBuilder).build(String(i)), ); } return baseFlagObject; - }; + } /** * @internal */ - clone(): TestDataFlagBuilderImpl { - return new TestDataFlagBuilderImpl(this.key, this.data); + clone(): TestDataFlagBuilder { + return new TestDataFlagBuilder(this.key, this.data); } /** diff --git a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts index 2754f59e39..0baf4ef909 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts @@ -1,7 +1,6 @@ import { AttributeReference, TypeValidators } from '@launchdarkly/js-sdk-common'; -import { Clause } from '../evaluation/data/Clause'; -import TestDataFlagBuilder from './TestDataFlagBuilder'; import { variationForBoolean } from './booleanVariation'; +import { Clause } from '../../evaluation/data/Clause'; /** * A builder for feature flag rules to be used with [[TestDataFlagBuilder]]. @@ -17,7 +16,7 @@ import { variationForBoolean } from './booleanVariation'; * Finally, call `thenReturn` to finish defining the rule. */ -export default class TestDataRuleBuilder { +export default class TestDataRuleBuilder { private clauses: Clause[] = []; private variation?: number; @@ -26,9 +25,13 @@ export default class TestDataRuleBuilder { * @internal */ constructor( - private readonly flagBuilder: TestDataFlagBuilder, + private readonly flagBuilder: BuilderType & { + addRule: (rule: TestDataRuleBuilder) => void, + booleanFlag: () => BuilderType, + }, clauses?: Clause[], - variation?: number) { + variation?: number, + ) { if (clauses) { this.clauses = JSON.parse(JSON.stringify(clauses)); } @@ -53,13 +56,17 @@ export default class TestDataRuleBuilder { * @param values values to compare to * @return the flag rule builder */ - andMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + andMatch( + contextKind: string, + attribute: string, + ...values: any + ): TestDataRuleBuilder { this.clauses.push({ contextKind, - attribute: attribute, + attribute, attributeReference: new AttributeReference(attribute), op: 'in', - values: values, + values, negate: false, }); return this; @@ -81,13 +88,17 @@ export default class TestDataRuleBuilder { * @param values values to compare to * @return the flag rule builder */ - andNotMatch(contextKind: string, attribute: string, ...values: any): TestDataRuleBuilder { + andNotMatch( + contextKind: string, + attribute: string, + ...values: any + ): TestDataRuleBuilder { this.clauses.push({ contextKind, - attribute: attribute, + attribute, attributeReference: new AttributeReference(attribute), op: 'in', - values: values, + values, negate: true, }); return this; @@ -107,7 +118,7 @@ export default class TestDataRuleBuilder { * 0 for the first, 1 for the second, etc. * @return the flag rule builder */ - thenReturn(variation: number | boolean): TestDataFlagBuilder { + thenReturn(variation: number | boolean): BuilderType { if (TypeValidators.Boolean.is(variation)) { this.flagBuilder.booleanFlag(); return this.thenReturn(variationForBoolean(variation)); @@ -123,16 +134,16 @@ export default class TestDataRuleBuilder { */ build(id: string) { return { - id: 'rule' + id, + id: `rule${id}`, variation: this.variation, clauses: this.clauses, }; - }; + } /** * @internal */ - clone(): TestDataRuleBuilder { - return new TestDataRuleBuilder(this.flagBuilder, this.clauses, this.variation); + clone(): TestDataRuleBuilder { + return new TestDataRuleBuilder(this.flagBuilder, this.clauses, this.variation); } } diff --git a/server-sdk-common/src/integrations/test_data/TestDataSource.ts b/server-sdk-common/src/integrations/test_data/TestDataSource.ts index 43c38fa628..6eccfa09b2 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataSource.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataSource.ts @@ -1,21 +1,23 @@ -import { LDStreamProcessor } from '../api'; -import { DataKind } from '../api/interfaces'; -import { LDKeyedFeatureStoreItem } from '../api/subsystems'; -import { Flag } from '../evaluation/data/Flag'; -import { Segment } from '../evaluation/data/Segment'; -import AsyncStoreFacade from '../store/AsyncStoreFacade'; -import VersionedDataKinds from '../store/VersionedDataKinds'; +import { LDStreamProcessor } from '../../api'; +import { DataKind } from '../../api/interfaces'; +import { LDKeyedFeatureStoreItem } from '../../api/subsystems'; +import { Flag } from '../../evaluation/data/Flag'; +import { Segment } from '../../evaluation/data/Segment'; +import AsyncStoreFacade from '../../store/AsyncStoreFacade'; +import VersionedDataKinds from '../../store/VersionedDataKinds'; /** * @internal */ -export class TestDataSource implements LDStreamProcessor { +export default class TestDataSource implements LDStreamProcessor { constructor( private readonly featureStore: AsyncStoreFacade, private readonly flags: Record, private readonly segments: Record, - private readonly onStop: (tfs: TestDataSource) => void) { + private readonly onStop: (tfs: TestDataSource) => void, + ) { } + async start(fn?: ((err?: any) => void) | undefined) { await this.featureStore.init({ [VersionedDataKinds.Features.namespace]: { ...this.flags }, @@ -33,6 +35,6 @@ export class TestDataSource implements LDStreamProcessor { } async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { - this.featureStore.upsert(kind, value); + return this.featureStore.upsert(kind, value); } } From 23f966f32eced0cdb0e4e27ed727521c72874e79 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Jul 2022 15:47:10 -0700 Subject: [PATCH 3/6] Formatting fixes. --- .../src/integrations/test_data/TestData.ts | 103 +++---- .../test_data/TestDataFlagBuilder.ts | 272 +++++++++--------- .../test_data/TestDataRuleBuilder.ts | 56 ++-- .../integrations/test_data/TestDataSource.ts | 50 ++-- .../test_data/booleanVariation.ts | 5 +- 5 files changed, 245 insertions(+), 241 deletions(-) diff --git a/server-sdk-common/src/integrations/test_data/TestData.ts b/server-sdk-common/src/integrations/test_data/TestData.ts index e4356f288c..6e1347c8ee 100644 --- a/server-sdk-common/src/integrations/test_data/TestData.ts +++ b/server-sdk-common/src/integrations/test_data/TestData.ts @@ -21,7 +21,8 @@ import TestDataSource from './TestDataSource'; * * const td = TestData(); * testData.update(td.flag("flag-key-1").booleanFlag().variationForAll(true)); - * const client = new LDClient(sdkKey, { updateProcessor: td.getFactory() }); + * TKTK: May need to update this, depending on how we show importing the client. + * const client = LDClient.init(sdkKey, { updateProcessor: td.getFactory() }); * * // flags can be updated at any time: * td.update(td.flag("flag-key-2") @@ -73,25 +74,25 @@ export default class TestData { } /** - * Creates or copies a [[TestDataFlagBuilder]] for building a test flag configuration. - * - * If the flag key has already been defined in this `TestData` instance, - * then the builder starts with the same configuration that was last - * provided for this flag. - * - * Otherwise, it starts with a new default configuration in which the flag - * has `true` and `false` variations, is `true` for all users when targeting - * is turned on and `false` otherwise, and currently has targeting turned on. - * You can change any of those properties and provide more complex behavior - * using the `TestDataFlagBuilder` methods. - * - * Once you have set the desired configuration, pass the builder to - * [[TestData.update]]. - * - * @param key the flag key - * @returns a flag configuration builder - * - */ + * Creates or copies a [[TestDataFlagBuilder]] for building a test flag configuration. + * + * If the flag key has already been defined in this `TestData` instance, + * then the builder starts with the same configuration that was last + * provided for this flag. + * + * Otherwise, it starts with a new default configuration in which the flag + * has `true` and `false` variations, is `true` for all users when targeting + * is turned on and `false` otherwise, and currently has targeting turned on. + * You can change any of those properties and provide more complex behavior + * using the `TestDataFlagBuilder` methods. + * + * Once you have set the desired configuration, pass the builder to + * [[TestData.update]]. + * + * @param key the flag key + * @returns a flag configuration builder + * + */ flag(key: string): TestDataFlagBuilder { if (this.flagBuilders[key]) { return this.flagBuilders[key].clone(); @@ -100,21 +101,21 @@ export default class TestData { } /** - * Updates the test data with the specified flag configuration. - * - * This has the same effect as if a flag were added or modified in the - * LaunchDarkly dashboard. It immediately propagates the flag changes to - * any [[LDClient]] instance(s) that you have already configured to use - * this `TestData`. If no `LDClient` has been started yet, it simply adds - * this flag to the test data which will be provided to any `LDClient` - * that you subsequently configure. - * - * Any subsequent changes to this `TestDataFlagBuilder` instance do not affect - * the test data unless you call `update` again. - * - * @param flagBuilder a flag configuration builder - * @return a promise that will resolve when the feature stores are updated - */ + * Updates the test data with the specified flag configuration. + * + * This has the same effect as if a flag were added or modified in the + * LaunchDarkly dashboard. It immediately propagates the flag changes to + * any [[LDClient]] instance(s) that you have already configured to use + * this `TestData`. If no `LDClient` has been started yet, it simply adds + * this flag to the test data which will be provided to any `LDClient` + * that you subsequently configure. + * + * Any subsequent changes to this `TestDataFlagBuilder` instance do not affect + * the test data unless you call `update` again. + * + * @param flagBuilder a flag configuration builder + * @return a promise that will resolve when the feature stores are updated + */ update(flagBuilder: TestDataFlagBuilder): Promise { const flagKey = flagBuilder.getKey(); const oldItem = this.currentFlags[flagKey]; @@ -129,22 +130,22 @@ export default class TestData { } /** - * Copies a full feature flag data model object into the test data. - * - * It immediately propagates the flag change to any [[LDClient]] instance(s) that you have already - * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds this - * flag to the test data which will be provided to any LDClient that you subsequently configure. - * - * Use this method if you need to use advanced flag configuration properties that are not - * supported by the simplified [[TestDataFlagBuilder]] API. Otherwise it is recommended to use the - * regular [[flag]]/[[update]] mechanism to avoid dependencies on details of the data model. - * - * You cannot make incremental changes with [[flag]]/[[update]] to a flag that has been added in - * this way; you can only replace it with an entirely new flag configuration. - * - * @param flagConfig the flag configuration as a JSON object - * @return a promise that will resolve when the feature stores are updated - */ + * Copies a full feature flag data model object into the test data. + * + * It immediately propagates the flag change to any [[LDClient]] instance(s) that you have already + * configured to use this `TestData`. If no [[LDClient]] has been started yet, it simply adds this + * flag to the test data which will be provided to any LDClient that you subsequently configure. + * + * Use this method if you need to use advanced flag configuration properties that are not + * supported by the simplified [[TestDataFlagBuilder]] API. Otherwise it is recommended to use the + * regular [[flag]]/[[update]] mechanism to avoid dependencies on details of the data model. + * + * You cannot make incremental changes with [[flag]]/[[update]] to a flag that has been added in + * this way; you can only replace it with an entirely new flag configuration. + * + * @param flagConfig the flag configuration as a JSON object + * @return a promise that will resolve when the feature stores are updated + */ usePreconfiguredFlag(inConfig: any): Promise { // We need to do things like process attribute reference, and // we do not want to modify the passed in value. diff --git a/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts index 981ff779e5..fc39d67f09 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataFlagBuilder.ts @@ -94,35 +94,35 @@ export default class TestDataFlagBuilder { } /** - * Sets targeting to be on or off for this flag. - * - * The effect of this depends on the rest of the flag configuration, just - * as it does on the real LaunchDarkly dashboard. In the default configuration - * that you get from calling [[TestData.flag]] with a new flag key, the flag - * will return `false` whenever targeting is off and `true` when targeting - * is on. - * - * @param targetingOn true if targeting should be on - * @return the flag builder - */ + * Sets targeting to be on or off for this flag. + * + * The effect of this depends on the rest of the flag configuration, just + * as it does on the real LaunchDarkly dashboard. In the default configuration + * that you get from calling [[TestData.flag]] with a new flag key, the flag + * will return `false` whenever targeting is off and `true` when targeting + * is on. + * + * @param targetingOn true if targeting should be on + * @return the flag builder + */ on(targetingOn: boolean): TestDataFlagBuilder { this.data.on = targetingOn; return this; } /** - * Specifies the fallthrough variation for a flag. The fallthrough is - * the value that is returned if targeting is on and the user was not - * matched by a more specific target or rule. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param variation - * either `true` or `false` or the index of the desired fallthrough - * variation: 0 for the first, 1 for the second, etc. - * @return the flag builder - */ + * Specifies the fallthrough variation for a flag. The fallthrough is + * the value that is returned if targeting is on and the user was not + * matched by a more specific target or rule. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param variation + * either `true` or `false` or the index of the desired fallthrough + * variation: 0 for the first, 1 for the second, etc. + * @return the flag builder + */ fallthroughVariation(variation: number | boolean): TestDataFlagBuilder { if (TypeValidators.Boolean.is(variation)) { return this.booleanFlag().fallthroughVariation(variationForBoolean(variation)); @@ -132,17 +132,17 @@ export default class TestDataFlagBuilder { } /** - * Specifies the off variation for a flag. This is the variation that is - * returned whenever targeting is off. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param variation - * either `true` or `false` or the index of the desired off - * variation: 0 for the first, 1 for the second, etc. - * @return the flag builder - */ + * Specifies the off variation for a flag. This is the variation that is + * returned whenever targeting is off. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param variation + * either `true` or `false` or the index of the desired off + * variation: 0 for the first, 1 for the second, etc. + * @return the flag builder + */ offVariation(variation: number | boolean): TestDataFlagBuilder { if (TypeValidators.Boolean.is(variation)) { return this.booleanFlag().offVariation(variationForBoolean(variation)); @@ -152,81 +152,81 @@ export default class TestDataFlagBuilder { } /** - * Sets the flag to always return the specified variation for all contexts. - * - * Targeting is switched on, any existing targets or rules are removed, - * and the fallthrough variation is set to the specified value. The off - * variation is left unchanged. - * - * If a boolean is supplied, and the flag was previously configured with - * other variations, this also changes it to a boolean flag. - * - * @param varation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ + * Sets the flag to always return the specified variation for all contexts. + * + * Targeting is switched on, any existing targets or rules are removed, + * and the fallthrough variation is set to the specified value. The off + * variation is left unchanged. + * + * If a boolean is supplied, and the flag was previously configured with + * other variations, this also changes it to a boolean flag. + * + * @param varation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ variationForAll(variation: number | boolean): TestDataFlagBuilder { return this.on(true).clearRules().clearAllTargets().fallthroughVariation(variation); } /** - * Sets the flag to always return the specified variation value for all contexts. - * - * The value may be of any valid JSON type. This method changes the flag to have - * only a single variation, which is this value, and to return the same variation - * regardless of whether targeting is on or off. Any existing targets or rules - * are removed. - * - * @param value The desired value to be returned for all contexts. - * @return the flag builder - */ + * Sets the flag to always return the specified variation value for all contexts. + * + * The value may be of any valid JSON type. This method changes the flag to have + * only a single variation, which is this value, and to return the same variation + * regardless of whether targeting is on or off. Any existing targets or rules + * are removed. + * + * @param value The desired value to be returned for all contexts. + * @return the flag builder + */ valueForAll(value: any): TestDataFlagBuilder { return this.variations(value).variationForAll(0); } /** - * Sets the flag to return the specified variation for a specific context key - * when targeting is on. The context kind for contexts created with this method - * will be 'user'. - * - * This has no effect when targeting is turned off for the flag. - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - * - * @param contextKey a context key - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ + * Sets the flag to return the specified variation for a specific context key + * when targeting is on. The context kind for contexts created with this method + * will be 'user'. + * + * This has no effect when targeting is turned off for the flag. + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + * + * @param contextKey a context key + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ variationForUser(contextKey: string, variation: number | boolean): TestDataFlagBuilder { return this.variationForContext('user', contextKey, variation); } /** - * Sets the flag to return the specified variation for a specific context key - * when targeting is on. - * - * This has no effect when targeting is turned off for the flag. - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - * - * @param contextKind a context kind - * @param contextKey a context key - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag builder - */ + * Sets the flag to return the specified variation for a specific context key + * when targeting is on. + * + * This has no effect when targeting is turned off for the flag. + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + * + * @param contextKind a context kind + * @param contextKey a context key + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag builder + */ variationForContext( contextKind: string, contextKey: string, @@ -281,44 +281,44 @@ export default class TestDataFlagBuilder { } /** - * Removes any existing rules from the flag. This undoes the effect of methods - * like [[ifMatch]]. - * - * @return the same flag builder - */ + * Removes any existing rules from the flag. This undoes the effect of methods + * like [[ifMatch]]. + * + * @return the same flag builder + */ clearRules(): TestDataFlagBuilder { delete this.data.rules; return this; } /** - * Removes any existing targets from the flag. This undoes the effect of - * methods like [[variationForContext]]. - * - * @return the same flag builder - */ + * Removes any existing targets from the flag. This undoes the effect of + * methods like [[variationForContext]]. + * + * @return the same flag builder + */ clearAllTargets(): TestDataFlagBuilder { delete this.data.targetsByVariation; return this; } /** - * Starts defining a flag rule using the "is one of" operator. - * - * For example, this creates a rule that returnes `true` if the name is - * "Patsy" or "Edina": - * - * testData.flag('flag') - * .ifMatch('user', name', 'Patsy', 'Edina') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the context attribute to match against - * @param values values to compare to - * @return - * a flag rule builder; call `thenReturn` to finish the rule - * or add more tests with another method like `andMatch` - */ + * Starts defining a flag rule using the "is one of" operator. + * + * For example, this creates a rule that returnes `true` if the name is + * "Patsy" or "Edina": + * + * testData.flag('flag') + * .ifMatch('user', name', 'Patsy', 'Edina') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the context attribute to match against + * @param values values to compare to + * @return + * a flag rule builder; call `thenReturn` to finish the rule + * or add more tests with another method like `andMatch` + */ ifMatch( contextKind: string, attribute: string, @@ -329,22 +329,22 @@ export default class TestDataFlagBuilder { } /** - * Starts defining a flag rule using the "is not one of" operator. - * - * For example, this creates a rule that returns `true` if the name is - * neither "Saffron" nor "Bubble": - * - * testData.flag('flag') - * .ifNotMatch('user', 'name', 'Saffron', 'Bubble') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the user attribute to match against - * @param values values to compare to - * @return - * a flag rule builder; call `thenReturn` to finish the rule - * or add more tests with another method like `andNotMatch` - */ + * Starts defining a flag rule using the "is not one of" operator. + * + * For example, this creates a rule that returns `true` if the name is + * neither "Saffron" nor "Bubble": + * + * testData.flag('flag') + * .ifNotMatch('user', 'name', 'Saffron', 'Bubble') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the user attribute to match against + * @param values values to compare to + * @return + * a flag rule builder; call `thenReturn` to finish the rule + * or add more tests with another method like `andNotMatch` + */ ifNotMatch( contextKind: string, attribute: string, diff --git a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts index 0baf4ef909..9d22f645e3 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts @@ -73,21 +73,21 @@ export default class TestDataRuleBuilder { } /** - * Adds another clause using the "is not one of" operator. - * - * For example, this creates a rule that returns `true` if the name is - * "Patsy" and the country is not "gb": - * - * testData.flag('flag') - * .ifMatch('name', 'Patsy') - * .andNotMatch('country', 'gb') - * .thenReturn(true) - * - * @param contextKind the kind of the context - * @param attribute the user attribute to match against - * @param values values to compare to - * @return the flag rule builder - */ + * Adds another clause using the "is not one of" operator. + * + * For example, this creates a rule that returns `true` if the name is + * "Patsy" and the country is not "gb": + * + * testData.flag('flag') + * .ifMatch('name', 'Patsy') + * .andNotMatch('country', 'gb') + * .thenReturn(true) + * + * @param contextKind the kind of the context + * @param attribute the user attribute to match against + * @param values values to compare to + * @return the flag rule builder + */ andNotMatch( contextKind: string, attribute: string, @@ -105,19 +105,19 @@ export default class TestDataRuleBuilder { } /** - * Finishes defining the rule, specifying the result value as either a boolean or an index - * - * If the variation is a boolean value and the flag was not already a boolean - * flag, this also changes it to be a boolean flag. - * - * If the variation is an integer, it specifies a variation out of whatever - * variation values have already been defined. - - * @param variation - * either `true` or `false` or the index of the desired variation: - * 0 for the first, 1 for the second, etc. - * @return the flag rule builder - */ + * Finishes defining the rule, specifying the result value as either a boolean or an index + * + * If the variation is a boolean value and the flag was not already a boolean + * flag, this also changes it to be a boolean flag. + * + * If the variation is an integer, it specifies a variation out of whatever + * variation values have already been defined. + * + * @param variation + * either `true` or `false` or the index of the desired variation: + * 0 for the first, 1 for the second, etc. + * @return the flag rule builder + */ thenReturn(variation: number | boolean): BuilderType { if (TypeValidators.Boolean.is(variation)) { this.flagBuilder.booleanFlag(); diff --git a/server-sdk-common/src/integrations/test_data/TestDataSource.ts b/server-sdk-common/src/integrations/test_data/TestDataSource.ts index 6eccfa09b2..1b6a9660df 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataSource.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataSource.ts @@ -7,34 +7,34 @@ import AsyncStoreFacade from '../../store/AsyncStoreFacade'; import VersionedDataKinds from '../../store/VersionedDataKinds'; /** - * @internal - */ + ** @internal + **/ export default class TestDataSource implements LDStreamProcessor { - constructor( - private readonly featureStore: AsyncStoreFacade, - private readonly flags: Record, - private readonly segments: Record, - private readonly onStop: (tfs: TestDataSource) => void, - ) { - } + *constructor( + *private readonly featureStore: AsyncStoreFacade, + *private readonly flags: Record, + *private readonly segments: Record, + *private readonly onStop: (tfs: TestDataSource) => void, + *) { + *} - async start(fn?: ((err?: any) => void) | undefined) { - await this.featureStore.init({ - [VersionedDataKinds.Features.namespace]: { ...this.flags }, - [VersionedDataKinds.Segments.namespace]: { ...this.segments }, - }); - fn?.(); - } + *async start(fn?: ((err?: any) => void) | undefined) { + *await this.featureStore.init({ + *[VersionedDataKinds.Features.namespace]: { ...this.flags }, + *[VersionedDataKinds.Segments.namespace]: { ...this.segments }, + *}); + *fn?.(); + *} - stop() { - this.onStop(this); - } + *stop() { + *this.onStop(this); + *} - close() { - this.stop(); - } + *close() { + *this.stop(); + *} - async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { - return this.featureStore.upsert(kind, value); - } + *async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { + *return this.featureStore.upsert(kind, value); + *} } diff --git a/server-sdk-common/src/integrations/test_data/booleanVariation.ts b/server-sdk-common/src/integrations/test_data/booleanVariation.ts index 26d6ce5819..079a86a2a2 100644 --- a/server-sdk-common/src/integrations/test_data/booleanVariation.ts +++ b/server-sdk-common/src/integrations/test_data/booleanVariation.ts @@ -1,6 +1,9 @@ export const TRUE_VARIATION_INDEX = 0; export const FALSE_VARIATION_INDEX = 1; -export function variationForBoolean(val: boolean) { +/** + * @internal + */ +export function variationForBoolean(val: boolean) { return val ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX; } From 1039bb7d3ec47193bffa9d046721fd1623bcacf2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Jul 2022 15:56:34 -0700 Subject: [PATCH 4/6] Fix test data source file. --- .../integrations/test_data/TestDataSource.ts | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/server-sdk-common/src/integrations/test_data/TestDataSource.ts b/server-sdk-common/src/integrations/test_data/TestDataSource.ts index 1b6a9660df..6eccfa09b2 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataSource.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataSource.ts @@ -7,34 +7,34 @@ import AsyncStoreFacade from '../../store/AsyncStoreFacade'; import VersionedDataKinds from '../../store/VersionedDataKinds'; /** - ** @internal - **/ + * @internal + */ export default class TestDataSource implements LDStreamProcessor { - *constructor( - *private readonly featureStore: AsyncStoreFacade, - *private readonly flags: Record, - *private readonly segments: Record, - *private readonly onStop: (tfs: TestDataSource) => void, - *) { - *} + constructor( + private readonly featureStore: AsyncStoreFacade, + private readonly flags: Record, + private readonly segments: Record, + private readonly onStop: (tfs: TestDataSource) => void, + ) { + } - *async start(fn?: ((err?: any) => void) | undefined) { - *await this.featureStore.init({ - *[VersionedDataKinds.Features.namespace]: { ...this.flags }, - *[VersionedDataKinds.Segments.namespace]: { ...this.segments }, - *}); - *fn?.(); - *} + async start(fn?: ((err?: any) => void) | undefined) { + await this.featureStore.init({ + [VersionedDataKinds.Features.namespace]: { ...this.flags }, + [VersionedDataKinds.Segments.namespace]: { ...this.segments }, + }); + fn?.(); + } - *stop() { - *this.onStop(this); - *} + stop() { + this.onStop(this); + } - *close() { - *this.stop(); - *} + close() { + this.stop(); + } - *async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { - *return this.featureStore.upsert(kind, value); - *} + async upsert(kind: DataKind, value: LDKeyedFeatureStoreItem) { + return this.featureStore.upsert(kind, value); + } } From a0bf11947f93d6c703c487791358727bb053dce5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Jul 2022 16:00:46 -0700 Subject: [PATCH 5/6] Lint. --- .../__tests__/integrations/test_data/TestData.test.ts | 2 +- server-sdk-common/src/integrations/test_data/TestData.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts index 227b5c582c..ce41ce75b8 100644 --- a/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts +++ b/server-sdk-common/__tests__/integrations/test_data/TestData.test.ts @@ -1,4 +1,4 @@ -import { AttributeReference, LDClientImpl } from '../../../src'; +import { AttributeReference } from '../../../src'; import { Flag } from '../../../src/evaluation/data/Flag'; import { FlagRule } from '../../../src/evaluation/data/FlagRule'; import TestData from '../../../src/integrations/test_data/TestData'; diff --git a/server-sdk-common/src/integrations/test_data/TestData.ts b/server-sdk-common/src/integrations/test_data/TestData.ts index 6e1347c8ee..c1a44e3489 100644 --- a/server-sdk-common/src/integrations/test_data/TestData.ts +++ b/server-sdk-common/src/integrations/test_data/TestData.ts @@ -184,7 +184,7 @@ export default class TestData { ...segmentConfig, version: oldItem ? oldItem.version + 1 : segmentConfig.version, }; - processFlag(newItem); + processSegment(newItem); this.currentSegments[segmentConfig.key] = newItem; return Promise.all( From 44a3ed2b3d32187326c957ebaa2ed674130eae18 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 1 Aug 2022 11:51:39 -0700 Subject: [PATCH 6/6] Change clauses to shallow copy. --- .../src/integrations/test_data/TestDataRuleBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts index 9d22f645e3..3c2785595e 100644 --- a/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts +++ b/server-sdk-common/src/integrations/test_data/TestDataRuleBuilder.ts @@ -33,7 +33,7 @@ export default class TestDataRuleBuilder { variation?: number, ) { if (clauses) { - this.clauses = JSON.parse(JSON.stringify(clauses)); + this.clauses = [...clauses]; } if (variation !== undefined) { this.variation = variation;