unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
412 lines • 18.9 kB
JavaScript
import fc from 'fast-check';
import { clientFeature, clientFeatures } from '../../../arbitraries.test.js';
import { generate as generateRequest } from '../../../../lib/openapi/spec/playground-request-schema.test.js';
import dbInit from '../../helpers/database-init.js';
import { setupAppWithCustomConfig, } from '../../helpers/test-helper.js';
import { WeightType } from '../../../../lib/types/model.js';
import getLogger from '../../../fixtures/no-logger.js';
import { ALL } from '../../../../lib/types/models/api-token.js';
import { ApiTokenType } from '../../../../lib/types/model.js';
import { DEFAULT_ENV } from '../../../../lib/server-impl.js';
let app;
let db;
let token;
beforeAll(async () => {
db = await dbInit('playground_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {}, db.rawDatabase);
const { apiTokenService } = app.services;
token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.ADMIN,
tokenName: 'tester',
environment: ALL,
projects: [ALL],
});
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
const reset = (database) => async () => {
await database.stores.featureToggleStore.deleteAll();
await database.stores.featureStrategiesStore.deleteAll();
await database.stores.environmentStore.deleteAll();
};
const toArray = (x) => [x];
const testParams = {
numRuns: 1, // Default is 100 which is not good for E2E tests
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 playgroundRequest = async (testApp, secret, request) => {
const { body, } = await testApp.request
.post('/api/admin/playground')
.set('Authorization', secret)
.send(request)
.expect(200);
return body;
};
describe('Playground API E2E', () => {
// utility function for seeding the database before runs
const seedDatabase = async (database, features, environment) => {
// 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.
});
return Promise.all(features.map(async (feature) => {
// create feature
const toggle = await database.stores.featureToggleStore.create(feature.project, {
...feature,
createdAt: undefined,
variants: null,
});
// 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((strategy, index) => database.stores.featureStrategiesStore.createStrategyFeatureEnv({
parameters: {},
constraints: [],
...strategy,
featureName: feature.name,
environment,
strategyName: strategy.name,
disabled: !!(index % 2),
projectId: feature.project,
})));
return toggle;
}));
};
it('Returned features should be a subset of the provided flags', async () => {
await fc.assert(fc
.asyncProperty(clientFeatures({ minLength: 1 }), generateRequest(), async (features, request) => {
// seed the database
await seedDatabase(db, features, request.environment);
const body = await playgroundRequest(app, token.secret, request);
// the returned list should always be a subset of the provided list
expect(features.map((feature) => feature.name)).toEqual(expect.arrayContaining(body.features.map((feature) => feature.name)));
})
.afterEach(reset(db)), testParams);
});
it('should filter the list according to the input parameters', async () => {
await fc.assert(fc
.asyncProperty(generateRequest(), clientFeatures({ minLength: 1 }), async (request, features) => {
await seedDatabase(db, features, request.environment);
// get a subset of projects that exist among the features
const [projects] = fc.sample(fc.oneof(fc.constant(ALL), fc.uniqueArray(fc.constantFrom(...features.map((feature) => feature.project)))));
request.projects = projects;
// create a list of features that can be filtered
// pass in args that should filter the list
const body = await playgroundRequest(app, token.secret, request);
switch (projects) {
case ALL:
// no features have been filtered out
return body.features.length === features.length;
case []:
// no feature should be without a project
return body.features.length === 0;
default:
// every feature should be in one of the prescribed projects
return body.features.every((feature) => projects.includes(feature.projectId));
}
})
.afterEach(reset(db)), testParams);
});
it('should map project and name correctly', async () => {
// note: we're not testing `isEnabled` and `variant` here, because
// that's the SDK's responsibility and it's tested elsewhere.
await fc.assert(fc
.asyncProperty(clientFeatures(), fc.context(), async (features, ctx) => {
await seedDatabase(db, features, 'default');
const body = await playgroundRequest(app, token.secret, {
projects: ALL,
environment: DEFAULT_ENV,
context: {
appName: 'playground-test',
},
});
const createDict = (xs) => xs.reduce((acc, next) => ({ ...acc, [next.name]: next }), {});
const mappedToggles = createDict(body.features);
if (features.length !== body.features.length) {
ctx.log(`I expected the number of mapped flags (${body.features.length}) to be the same as the number of created toggles (${features.length}), but that was not the case.`);
return false;
}
return features.every((feature) => {
const mapped = mappedToggles[feature.name];
expect(mapped).toBeTruthy();
return (feature.name === mapped.name &&
feature.project === mapped.projectId);
});
})
.afterEach(reset(db)), testParams);
});
it('isEnabledInCurrentEnvironment should always match feature.enabled', async () => {
await fc.assert(fc
.asyncProperty(clientFeatures(), fc.context(), async (features, ctx) => {
await seedDatabase(db, features, DEFAULT_ENV);
const body = await playgroundRequest(app, token.secret, {
projects: ALL,
environment: DEFAULT_ENV,
context: {
appName: 'playground-test',
},
});
const createDict = (xs) => xs.reduce((acc, next) => ({ ...acc, [next.name]: next }), {});
const mappedToggles = createDict(body.features);
ctx.log(JSON.stringify(features));
ctx.log(JSON.stringify(mappedToggles));
return features.every((feature) => feature.enabled ===
mappedToggles[feature.name]
.isEnabledInCurrentEnvironment);
})
.afterEach(reset(db)), testParams);
});
describe('context application', () => {
it('applies appName constraints correctly', async () => {
const appNames = ['A', 'B', 'C'];
// Choose one of the app names at random
const appName = () => fc.constantFrom(...appNames);
// generate a list of features that are active only for a specific
// app name (constraints). Each feature will be constrained to a
// random appName from the list above.
const constrainedFeatures = () => fc.uniqueArray(fc
.tuple(clientFeature(), fc.record({
name: fc.constant('default'),
constraints: fc
.record({
values: appName().map(toArray),
inverted: fc.constant(false),
operator: fc.constant('IN'),
contextName: fc.constant('appName'),
caseInsensitive: fc.boolean(),
})
.map(toArray),
}))
.map(([feature, strategy]) => ({
...feature,
enabled: true,
strategies: [strategy],
})), { selector: (feature) => feature.name });
await fc.assert(fc
.asyncProperty(fc
.tuple(appName(), generateRequest())
.map(([generatedAppName, req]) => ({
...req,
// generate a context that has appName set to
// one of the above values
context: {
appName: generatedAppName,
environment: DEFAULT_ENV,
},
})), constrainedFeatures(), async (req, features) => {
await seedDatabase(db, features, req.environment);
const body = await playgroundRequest(app, token.secret, req);
const shouldBeEnabled = features.reduce((acc, next) => ({
...acc,
[next.name]:
// @ts-expect-error
next.strategies[0].constraints[0]
.values[0] === req.context.appName,
}), {});
return body.features.every((feature) => feature.isEnabled ===
shouldBeEnabled[feature.name]);
})
.afterEach(reset(db)), {
...testParams,
examples: [],
});
});
it('applies dynamic context fields correctly', async () => {
const contextValue = () => fc.oneof(fc.record({
name: fc.constant('remoteAddress'),
value: fc.ipV4(),
operator: fc.constant('IN'),
}), fc.record({
name: fc.constant('sessionId'),
value: fc.uuid(),
operator: fc.constant('IN'),
}), fc.record({
name: fc.constant('userId'),
value: fc.emailAddress(),
operator: fc.constant('IN'),
}));
const constrainedFeatures = () => fc.uniqueArray(fc
.tuple(clientFeature(), contextValue().map((context) => ({
name: 'default',
constraints: [
{
values: [context.value],
inverted: false,
operator: context.operator,
contextName: context.name,
caseInsensitive: false,
},
],
})))
.map(([feature, strategy]) => ({
...feature,
enabled: true,
strategies: [strategy],
})), { selector: (feature) => feature.name });
await fc.assert(fc
.asyncProperty(fc
.tuple(contextValue(), generateRequest())
.map(([generatedContextValue, req]) => ({
...req,
// generate a context that has a dynamic context field set to
// one of the above values
context: {
...req.context,
[generatedContextValue.name]: generatedContextValue.value,
},
})), constrainedFeatures(), async (req, features) => {
await seedDatabase(db, features, 'default');
const body = await playgroundRequest(app, token.secret, req);
const contextField = Object.values(req.context)[0];
const shouldBeEnabled = features.reduce((acc, next) => ({
...acc,
[next.name]: next.strategies[0].constraints[0]
.values[0] === contextField,
}), {});
return body.features.every((feature) => feature.isEnabled ===
shouldBeEnabled[feature.name]);
})
.afterEach(reset(db)), testParams);
});
it('applies custom context fields correctly', async () => {
const environment = 'default';
const contextValue = () => fc.record({
name: fc.constantFrom('Context field A', 'Context field B'),
value: fc.constantFrom('Context value 1', 'Context value 2'),
});
const constrainedFeatures = () => fc.uniqueArray(fc
.tuple(clientFeature(), contextValue().map((context) => ({
name: 'default',
constraints: [
{
values: [context.value],
inverted: false,
operator: 'IN',
contextName: context.name,
caseInsensitive: false,
},
],
})))
.map(([feature, strategy]) => ({
...feature,
enabled: true,
strategies: [strategy],
})), { selector: (feature) => feature.name });
// generate a constraint to be used for the context and a request
// that contains that constraint value.
const constraintAndRequest = () => fc
.tuple(contextValue(), fc.constantFrom('top', 'nested'), generateRequest())
.map(([generatedContextValue, placement, req]) => {
const request = placement === 'top'
? {
...req,
environment,
context: {
...req.context,
[generatedContextValue.name]: generatedContextValue.value,
},
}
: {
...req,
environment,
context: {
...req.context,
properties: {
[generatedContextValue.name]: generatedContextValue.value,
},
},
};
return {
generatedContextValue,
request,
};
});
await fc.assert(fc
.asyncProperty(constraintAndRequest(), constrainedFeatures(), fc.context(), async ({ generatedContextValue, request }, features, ctx) => {
await seedDatabase(db, features, environment);
const body = await playgroundRequest(app, token.secret, request);
const shouldBeEnabled = features.reduce((acc, next) => {
const constraint = next.strategies[0].constraints[0];
return {
...acc,
[next.name]: constraint.contextName ===
generatedContextValue.name &&
constraint.values[0] ===
generatedContextValue.value,
};
}, {});
ctx.log(`Got these ${JSON.stringify(body.features)} and I expect them to be enabled/disabled: ${JSON.stringify(shouldBeEnabled)}`);
return body.features.every((feature) => feature.isEnabled ===
shouldBeEnabled[feature.name]);
})
.afterEach(reset(db)), testParams);
});
it('context is applied to variant checks', async () => {
const environment = 'development';
const featureName = 'feature-name';
const customContextFieldName = 'customField';
const customContextValue = 'customValue';
const features = [
{
project: 'any-project',
strategies: [
{
name: 'default',
constraints: [
{
contextName: customContextFieldName,
operator: 'IN',
values: [customContextValue],
},
],
},
],
stale: false,
enabled: true,
name: featureName,
type: 'experiment',
variants: [
{
name: 'a',
weight: 1000,
weightType: 'variable',
stickiness: 'default',
overrides: [],
},
],
},
];
await seedDatabase(db, features, environment);
const request = {
projects: ALL,
environment,
context: {
appName: 'playground',
[customContextFieldName]: customContextValue,
},
};
const body = await playgroundRequest(app, token.secret, request);
// when enabled, this toggle should have one of the variants
expect(body.features[0].variant.name).toBe('a');
});
});
});
//# sourceMappingURL=playground.e2e.test.js.map