UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

809 lines • 35.8 kB
import { PlaygroundService, } from '../../../lib/features/playground/playground-service.js'; import { clientFeaturesAndSegments, commonISOTimestamp, } from '../../arbitraries.test.js'; import { generate as generateContext } from '../../../lib/openapi/spec/sdk-context-schema.test.js'; import fc from 'fast-check'; import { createTestConfig } from '../../config/test-config.js'; import dbInit from '../helpers/database-init.js'; import { WeightType, } from '../../../lib/types/model.js'; import { offlineUnleashClientNode } from '../../../lib/features/playground/offline-unleash-client.test.js'; import { playgroundStrategyEvaluation } from '../../../lib/openapi/spec/playground-strategy-schema.js'; import { createPrivateProjectChecker } from '../../../lib/features/private-project/createPrivateProjectChecker.js'; import { createFeatureToggleService } from '../../../lib/features/index.js'; import { SegmentReadModel } from '../../../lib/features/segment/segment-read-model.js'; import { DEFAULT_ENV } from '../../../lib/server-impl.js'; let stores; let db; let service; let featureToggleService; beforeAll(async () => { const config = createTestConfig(); db = await dbInit('playground_service_serial', config.getLogger); stores = db.stores; const privateProjectChecker = createPrivateProjectChecker(db.rawDatabase, config); const segmentReadModel = new SegmentReadModel(db.rawDatabase); featureToggleService = createFeatureToggleService(db.rawDatabase, config); service = new PlaygroundService(config, { featureToggleService: featureToggleService, privateProjectChecker, }, segmentReadModel); }); afterAll(async () => { await db.destroy(); }); const cleanup = async () => { await stores.segmentStore.deleteAll(); await stores.featureToggleStore.deleteAll(); await stores.eventStore.deleteAll(); await stores.featureStrategiesStore.deleteAll(); await stores.segmentStore.deleteAll(); }; afterEach(cleanup); const testParams = { interruptAfterTimeLimit: 4000, // Default timeout in Jest 5000ms markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure }; const mapSegmentSchemaToISegment = (segment, index) => ({ ...segment, name: segment.name || `test-segment ${index ?? 'unnumbered'}`, createdAt: new Date(), description: '', project: undefined, }); export const seedDatabaseForPlaygroundTest = async (database, features, environment, segments) => { if (segments) { await Promise.all(segments.map(async (segment, index) => database.stores.segmentStore.create(mapSegmentSchemaToISegment(segment, index), { username: 'test' }))); } return Promise.all(features.map(async (feature) => { // create environment if necessary await database.stores.environmentStore .create({ name: environment, type: 'development', enabled: true, }) .catch(() => { // purposefully left empty: env creation may fail if the // env already exists, and because of the async nature // of things, this is the easiest way to make it work. }); // create feature const toggle = await database.stores.featureToggleStore.create(feature.project, { ...feature, createdAt: undefined, variants: [], description: undefined, impressionData: false, createdByUserId: 9999, }); // enable/disable the feature in environment await database.stores.featureEnvironmentStore.addEnvironmentToFeature(feature.name, environment, feature.enabled); await database.stores.featureEnvironmentStore.addVariantsToFeatureEnvironment(feature.name, environment, [ ...(feature.variants ?? []).map((variant) => ({ ...variant, weightType: WeightType.VARIABLE, stickiness: 'default', })), ]); // assign strategies await Promise.all((feature.strategies || []).map(async ({ segments: strategySegments, ...strategy }) => { await database.stores.featureStrategiesStore.createStrategyFeatureEnv({ parameters: {}, constraints: [], ...strategy, featureName: feature.name, environment, strategyName: strategy.name, projectId: feature.project, }); if (strategySegments) { await Promise.all(strategySegments.map((segmentId) => database.stores.segmentStore.addToStrategy(segmentId, strategy.id))); } })); return toggle; })); }; describe('the playground service (e2e)', () => { const isDisabledVariant = (variant) => variant?.name === 'disabled' && !variant?.enabled; const insertAndEvaluateFeatures = async ({ features, context, env = DEFAULT_ENV, segments, }) => { await seedDatabaseForPlaygroundTest(db, features, env, segments); const projects = '*'; const serviceFeatures = await service.evaluateQuery(projects, env, context); return serviceFeatures; }; test('should return the same enabled toggles as the raw SDK correctly mapped', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), fc .tuple(generateContext(), commonISOTimestamp()) .map(([context, currentTime]) => ({ ...context, userId: 'constant', sessionId: 'constant2', currentTime, })), fc.context(), async ({ segments, features }, context, ctx) => { const serviceToggles = await insertAndEvaluateFeatures({ features: features, context, segments, }); const [head, ...rest] = await featureToggleService.getClientFeatures(); if (!head) { return serviceToggles.length === 0; } const client = await offlineUnleashClientNode({ features: [head, ...rest], context, logError: console.log, segments: segments.map(mapSegmentSchemaToISegment), }); const clientContext = { ...context, currentTime: context.currentTime ? new Date(context.currentTime) : undefined, }; return serviceToggles.every((feature) => { ctx.log(`Examining feature ${feature.name}: ${JSON.stringify(feature)}`); // the playground differs from a normal SDK in that // it _must_ evaluate all strategies and features // regardless of whether they're supposed to be // enabled in the current environment or not. const expectedSDKState = feature.isEnabled; const enabledStateMatches = expectedSDKState === client.isEnabled(feature.name, clientContext); ctx.log(`feature.isEnabled, feature.isEnabledInCurrentEnvironment, presumedSDKState: ${feature.isEnabled}, ${feature.isEnabledInCurrentEnvironment}, ${expectedSDKState}`); ctx.log(`client.isEnabled: ${client.isEnabled(feature.name, clientContext)}`); expect(enabledStateMatches).toBe(true); // if x is disabled, then the variant will be the // disabled variant. if (!feature.isEnabled) { ctx.log(`${feature.name} is not enabled`); ctx.log(JSON.stringify(feature.variant)); ctx.log(JSON.stringify(enabledStateMatches)); ctx.log(JSON.stringify(feature.variant?.name === 'disabled')); ctx.log(JSON.stringify(feature.variant?.enabled === false)); return (enabledStateMatches && isDisabledVariant(feature.variant)); } ctx.log('feature is enabled'); const clientVariant = client.getVariant(feature.name, clientContext); // if x is enabled, but its variant is the disabled // variant, then the source does not have any // variants if (isDisabledVariant(feature.variant)) { return (enabledStateMatches && isDisabledVariant(clientVariant)); } ctx.log(`feature "${feature.name}" has a variant`); ctx.log(`Feature variant: ${JSON.stringify(feature.variant)}`); ctx.log(`Client variant: ${JSON.stringify(clientVariant)}`); ctx.log(`enabledStateMatches: ${enabledStateMatches}`); // variants should be the same if the // toggle is enabled in both versions. If // they're not and one of them has a // variant, then they should be different. if (expectedSDKState === true) { expect(feature.variant).toEqual(clientVariant); } else { expect(feature.variant).not.toEqual(clientVariant); } return enabledStateMatches; }); }) .afterEach(cleanup), { ...testParams, examples: [] }); }); // counterexamples found by fastcheck const counterexamples = [ [ [ { name: '-', type: 'release', project: 'A', enabled: true, lastSeenAt: '1970-01-01T00:00:00.000Z', impressionData: null, strategies: [], variants: [ { name: '-', weight: 147, weightType: 'variable', stickiness: 'default', payload: { type: 'string', value: '' }, }, { name: '~3dignissim~gravidaod', weight: 301, weightType: 'variable', stickiness: 'default', payload: { type: 'json', value: '{"Sv7gRNNl=":[true,"Mfs >mp.D","O-jtK","y%i\\"Ub~",null,"J",false,"(\'R"],"F0g+>1X":3.892913121148499e-188,"Fi~k(":-4.882970135331098e+146,"":null,"nPT]":true}', }, }, ], }, ], { appName: '"$#', currentTime: '9999-12-31T23:59:59.956Z', environment: 'r', }, { logs: [ 'feature is enabled', 'feature has a variant', '{"name":"-","payload":{"type":"string","value":""},"enabled":true}', '{"name":"~3dignissim~gravidaod","payload":{"type":"json","value":"{\\"Sv7gRNNl=\\":[true,\\"Mfs >mp.D\\",\\"O-jtK\\",\\"y%i\\\\\\"Ub~\\",null,\\"J\\",false,\\"(\'R\\"],\\"F0g+>1X\\":3.892913121148499e-188,\\"Fi~k(\\":-4.882970135331098e+146,\\"\\":null,\\"nPT]\\":true}"},"enabled":true}', 'true', 'false', ], }, ], [ [ { name: '-', project: '0', enabled: true, strategies: [ { name: 'default', constraints: [ { contextName: 'A', operator: 'NOT_IN', caseInsensitive: false, inverted: false, values: [], value: '', }, ], }, ], }, ], { appName: ' ', userId: 'constant', sessionId: 'constant2' }, { logs: [] }, ], [ [ { name: 'a', project: 'a', enabled: true, strategies: [ { name: 'default', constraints: [ { contextName: '0', operator: 'NOT_IN', caseInsensitive: false, inverted: false, values: [], value: '', }, ], }, ], }, { name: '-', project: 'elementum', enabled: false, strategies: [], }, ], { appName: ' ', userId: 'constant', sessionId: 'constant2' }, { logs: [ 'feature is not enabled', '{"name":"disabled","enabled":false}', ], }, ], [ [ { name: '0', project: '-', enabled: true, strategies: [ { name: 'default', constraints: [ { contextName: 'sed', operator: 'NOT_IN', caseInsensitive: false, inverted: false, values: [], value: '', }, ], }, ], }, ], { appName: ' ', userId: 'constant', sessionId: 'constant2' }, { logs: [ '0 is not enabled', '{"name":"disabled","enabled":false}', 'true', 'true', ], }, ], [ [ { name: '0', project: 'ac', enabled: true, strategies: [ { name: 'default', constraints: [ { contextName: '0', operator: 'NOT_IN', caseInsensitive: false, inverted: false, values: [], value: '', }, ], }, ], }, ], { appName: ' ', userId: 'constant', sessionId: 'constant2' }, { logs: [ 'feature.isEnabled: false', 'client.isEnabled: true', '0 is not enabled', '{"name":"disabled","enabled":false}', 'false', 'true', 'true', ], }, ], [ [ { name: '0', project: 'aliquam', enabled: true, strategies: [ { name: 'default', constraints: [ { contextName: '-', operator: 'NOT_IN', caseInsensitive: false, inverted: false, values: [], value: '', }, ], }, ], }, { name: '-', project: '-', enabled: false, strategies: [], }, ], { appName: ' ', userId: 'constant', sessionId: 'constant2', currentTime: '1970-01-01T00:00:00.000Z', }, { logs: [ 'feature.isEnabled: false', 'client.isEnabled: true', '0 is not enabled', '{"name":"disabled","enabled":false}', 'false', 'true', 'true', ], }, ], ]; // these tests test counterexamples found by fast check. The may seem redundant, but are concrete cases that might break. counterexamples.map(async ([features, context], i) => { it(`should do the same as the raw SDK: counterexample ${i}`, async () => { const serviceFeatures = await insertAndEvaluateFeatures({ // @ts-expect-error features, // @ts-expect-error context, }); const [head, ...rest] = await featureToggleService.getClientFeatures(); if (!head) { return serviceFeatures.length === 0; } const client = await offlineUnleashClientNode({ features: [head, ...rest], // @ts-expect-error context, logError: console.log, }); const clientContext = { ...context, // @ts-expect-error currentTime: context.currentTime ? // @ts-expect-error new Date(context.currentTime) : undefined, }; serviceFeatures.forEach((feature) => { expect(feature.isEnabled).toEqual( //@ts-expect-error client.isEnabled(feature.name, clientContext)); }); }); }); test("should return all of a feature's strategies", async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), fc.context(), async (data, context, ctx) => { const log = (x) => ctx.log(JSON.stringify(x)); const serviceFeatures = await insertAndEvaluateFeatures({ ...data, context, }); const serviceFeaturesDict = serviceFeatures.reduce((acc, feature) => ({ ...acc, [feature.name]: feature, }), {}); // for each feature, find the corresponding evaluated feature // and make sure that the evaluated // return genFeat.length === servFeat.length && zip(gen, serv). data.features.forEach((feature) => { const mappedFeature = serviceFeaturesDict[feature.name]; // log(feature); log(mappedFeature); const featureStrategies = feature.strategies ?? []; expect(mappedFeature.strategies.data.length).toEqual(featureStrategies.length); // we can't guarantee that the order we inserted // strategies into the database is the same as it // was returned by the service , so we'll need to // scan through the list of strats. // extract the `result` property, because it // doesn't exist in the input const removeResult = ({ result, ...rest }) => rest; const cleanedReceivedStrategies = mappedFeature.strategies.data.map((strategy) => { const { segments: mappedSegments, ...mappedStrategy } = removeResult(strategy); return { ...mappedStrategy, constraints: mappedStrategy.constraints?.map(removeResult), }; }); feature.strategies?.forEach(({ segments, ...strategy }) => { expect(cleanedReceivedStrategies).toEqual(expect.arrayContaining([ { ...strategy, title: undefined, disabled: false, constraints: strategy.constraints ?? [], parameters: strategy.parameters ?? {}, }, ])); }); }); }) .afterEach(cleanup), testParams); }); test('should return feature strategies with all their segments', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), async ({ segments, features: generatedFeatures }, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ features: generatedFeatures, context, segments, }); const serviceFeaturesDict = serviceFeatures.reduce((acc, feature) => ({ ...acc, [feature.name]: feature, }), {}); // ensure that segments are mapped on to features // correctly. We do not need to check whether the // evaluation is correct; that is taken care of by other // tests. // For each feature strategy, find its list of segments and // compare it to the input. // // We can assert three things: // // 1. The segments lists have the same length // // 2. All segment ids listed in an input id list are // also in the original segments list // // 3. If a feature is considered enabled, _all_ segments // must be true. If a feature is _disabled_, _at least_ // one segment is not true. generatedFeatures.forEach((unmappedFeature) => { const strategies = serviceFeaturesDict[unmappedFeature.name].strategies.data.reduce((acc, strategy) => ({ ...acc, [strategy.id]: strategy, }), {}); unmappedFeature.strategies?.forEach((unmappedStrategy) => { const mappedStrategySegments = strategies[unmappedStrategy.id] .segments; const unmappedSegments = unmappedStrategy.segments ?? []; // 1. The segments lists have the same length // 2. All segment ids listed in the input exist: expect([ ...mappedStrategySegments?.map((segment) => segment.id), ].sort()).toEqual([...unmappedSegments].sort()); switch (strategies[unmappedStrategy.id].result) { case true: // If a strategy is considered true, _all_ segments // must be true. expect(mappedStrategySegments.every((segment) => segment.result === true)).toBeTruthy(); break; case false: // empty -- all segments can be true and // the toggle still not enabled. We // can't check for anything here. case 'not found': // empty -- we can't evaluate this } }); }); }) .afterEach(cleanup), testParams); }); test("should evaluate a strategy to be unknown if it doesn't recognize the strategy and all constraints pass", async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }).map(({ features, ...rest }) => ({ ...rest, features: features.map((feature) => ({ ...feature, // remove any constraints and use a name that doesn't exist strategies: feature.strategies?.map((strategy) => ({ ...strategy, name: 'bogus-strategy', constraints: [], segments: [], })), })), })), generateContext(), fc.context(), async (featsAndSegments, context, ctx) => { const serviceFeatures = await insertAndEvaluateFeatures({ ...featsAndSegments, context, }); serviceFeatures.forEach((feature) => feature.strategies.data.forEach((strategy) => { expect(strategy.result.evaluationStatus).toBe(playgroundStrategyEvaluation.evaluationIncomplete); expect(strategy.result.enabled).toBe(playgroundStrategyEvaluation.unknownResult); })); ctx.log(JSON.stringify(serviceFeatures)); serviceFeatures.forEach((feature) => { // if there are strategies and they're all // incomplete and unknown, then the feature can't be // evaluated fully if (feature.strategies.data.length) { expect(feature.isEnabled).toBe(false); } }); }) .afterEach(cleanup), testParams); }); test("should evaluate a strategy as false if it doesn't recognize the strategy and constraint checks fail", async () => { await fc.assert(fc .asyncProperty(fc .tuple(fc.uuid(), clientFeaturesAndSegments({ minLength: 1 })) .map(([uuid, { features, ...rest }]) => ({ ...rest, features: features.map((feature) => ({ ...feature, // use a constraint that will never be true strategies: feature.strategies?.map((strategy) => ({ ...strategy, name: 'bogusStrategy', constraints: [ { contextName: 'appName', operator: 'IN', values: [uuid], }, ], })), })), })), generateContext(), fc.context(), async (featsAndSegments, context, ctx) => { const serviceFeatures = await insertAndEvaluateFeatures({ ...featsAndSegments, context, }); serviceFeatures.forEach((feature) => feature.strategies.data.forEach((strategy) => { expect(strategy.result.evaluationStatus).toBe(playgroundStrategyEvaluation.evaluationIncomplete); expect(strategy.result.enabled).toBe(false); })); ctx.log(JSON.stringify(serviceFeatures)); serviceFeatures.forEach((feature) => { if (feature.strategies.data.length) { // if there are strategies and they're all // incomplete and false, then the feature // is also false expect(feature.isEnabled).toEqual(false); } }); }) .afterEach(cleanup), testParams); }); test('should evaluate a feature as unknown if there is at least one incomplete strategy among all failed strategies', async () => { await fc.assert(fc .asyncProperty(fc .tuple(fc.uuid(), clientFeaturesAndSegments({ minLength: 1 })) .map(([uuid, { features, ...rest }]) => ({ ...rest, features: features.map((feature) => ({ ...feature, // use a constraint that will never be true strategies: [ ...feature.strategies.map((strategy) => ({ ...strategy, constraints: [ { contextName: 'appName', operator: 'IN', values: [uuid], }, ], })), { name: 'my-custom-strategy' }, ], })), })), generateContext(), async (featsAndSegments, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ ...featsAndSegments, context, }); serviceFeatures.forEach((feature) => { if (feature.strategies.data.length) { // if there are strategies and they're // all incomplete and unknown, then // the feature is also unknown and // thus 'false' (from an SDK point of // view) expect(feature.isEnabled).toEqual(false); } }); }) .afterEach(cleanup), testParams); }); test("features can't be evaluated to true if they're not enabled in the current environment", async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }).map(({ features, ...rest }) => ({ ...rest, features: features.map((feature) => ({ ...feature, enabled: false, // remove any constraints and use a name that doesn't exist strategies: [{ name: 'default' }], })), })), generateContext(), fc.context(), async (featsAndSegments, context, ctx) => { const serviceFeatures = await insertAndEvaluateFeatures({ ...featsAndSegments, context, }); serviceFeatures.forEach((feature) => feature.strategies.data.forEach((strategy) => { expect(strategy.result.evaluationStatus).toBe(playgroundStrategyEvaluation.evaluationComplete); expect(strategy.result.enabled).toBe(true); })); ctx.log(JSON.stringify(serviceFeatures)); serviceFeatures.forEach((feature) => { expect(feature.isEnabled).toBe(false); expect(feature.isEnabledInCurrentEnvironment).toBe(false); }); }) .afterEach(cleanup), testParams); }); test('output toggles should have the same variants as input toggles', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), async ({ features, segments }, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ features, segments, context, }); const variantsMap = features.reduce((acc, feature) => ({ ...acc, [feature.name]: feature.variants, }), {}); serviceFeatures.forEach((feature) => { if (variantsMap[feature.name]) { expect(feature.variants).toEqual(expect.arrayContaining(variantsMap[feature.name])); expect(variantsMap[feature.name]).toEqual(expect.arrayContaining(feature.variants)); } else { expect(feature.variants).toStrictEqual([]); } }); }) .afterEach(cleanup), testParams); }); test('isEnabled matches strategies.results', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), async ({ features, segments }, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ features, segments, context, }); serviceFeatures.forEach((feature) => { if (feature.isEnabled) { expect(feature.isEnabledInCurrentEnvironment).toBe(true); expect(feature.strategies.result).toBe(true); } else { expect(!feature.isEnabledInCurrentEnvironment || feature.strategies.result !== true).toBe(true); } }); }) .afterEach(cleanup), testParams); }); test('strategies.results matches the individual strategy results', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), async ({ features, segments }, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ features, segments, context, }); serviceFeatures.forEach(({ strategies }) => { if (strategies.result === false) { expect(strategies.data.every((strategy) => strategy.result.enabled === false)).toBe(true); } else if (strategies.result === playgroundStrategyEvaluation.unknownResult) { expect(strategies.data.some((strategy) => strategy.result.enabled === playgroundStrategyEvaluation.unknownResult)).toBe(true); expect(strategies.data.every((strategy) => strategy.result.enabled !== true)).toBe(true); } else { if (strategies.data.length > 0) { expect(strategies.data.some((strategy) => strategy.result.enabled === true)).toBe(true); } } }); }) .afterEach(cleanup), testParams); }); test('unevaluated features should not have variants', async () => { await fc.assert(fc .asyncProperty(clientFeaturesAndSegments({ minLength: 1 }), generateContext(), async ({ features, segments }, context) => { const serviceFeatures = await insertAndEvaluateFeatures({ features, segments, context, }); serviceFeatures.forEach((feature) => { if (feature.strategies.result === playgroundStrategyEvaluation.unknownResult) { expect(feature.variant).toEqual({ name: 'disabled', enabled: false, feature_enabled: false, }); } }); }) .afterEach(cleanup), testParams); }); }); //# sourceMappingURL=playground-service.test.js.map