1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: add playground API post endpoint (#1791)

* Chore: add unleash-client dependency

* Feat: add openapi specs for basic sdk contexts and playground reqs

* Feat: add openapi spec for response

* Feat: add openAPI entry for playground endpoint

* Fix: remove required 'projects' property that doesn't exist

* Refactor: add test placeholders for playground api

* Chore: improve openAPI description for playground request

* Refactor: align example project names

* Fix: update openapi snapshot

* Fix: summary -> description; example -> examples

* Feat: add fast-check and stub out one test.

The test doesn't work yet.

* Feat: add test and generator for sdk context schema

* Feat: add generators and tests for all new schemas

* Feat: test that the same input is returned exactly

* Feat: add more tests and more schemas

* Refactor: clean up tests some

* Refactor: simplify url friendliness check

* Refactor: fix types for playground request schema generator

* Feat: add bad request test

* Fix: make parameters the correct shape in generator

* Feat: scaffold out tests for service and endpoint

Liberal use of @ts-expect-error

* Fix: update snapshot with new model

* Add missing appName

* wip: stub out some extra tests

* Feat: model strategies for testing

* Feat: model variant payloads better

* Refactor: input params for function

* Refactor: use email address for user id generation

* Chore: update comment

* Wip: start stubbing out e2e tests

* Refactor: move tests requiring db to e2e file

* Fix: make e2e tests run as expected

* Fix: make toggle generator test work as expected

* Fix: increase timeout for long-running e2e test

* Wip: map toggles test

* Wip: test movement and cleanup

* Refactor: remove `AllowedStrings` in favor of typeof X[number]

* Wip: add e2e tests for the service

* Wip: tests in progress

* Feat: return fuller variant value

* Wip: delete redundant test.

Both those tests were testing versions of the same thing, so let's
delete one of them

* Feat: update openAPI snapshot

* Feat: hook up the playground service

* Feat: fix test cases for e2e api

* Refactor: get rid of _one_ 'as unknown as x' cast

* Fix: wait for the client to be ready before we pass it back

* Fix: weights go to 1000!

* Fix: fix some test assertions, add one more test to be sure

* Wip: add some test parameters to hopefully avoid timeouts

* Fix: don't init the client if there are no toggles

If you don't provide it with any toggles to bootstrap, then the client
will never be ready. This avoids that.

This adds two ways around it because we'll probably get away with a
bit less computation if we don't even try to initialize it. However,
we also don't want anyone else to make this mistake.

* Chore: modify test params

* Feat: use the type system to avoid empty lists for initialization

* Refactor: make tests simpler

* Feat: test basic context values

* Feat: test dynamic context values

* Fix: make custom context field test more reliable

* Feat: use unique arrays for toggles

* Feat: make `appName` required in context

* Fix: sdk context schema arbitrary: make appName at least 1 char long

* Fix: add a minimum length on appName in the context schema

* Fix: fix tests that fail due to new requirements

* Fix: update a test: remove variant mapping testing

* Fix: fix remaining tests

* Feat: rename response.toggles -> response.features

* Refactor: avoid single-letter predicate variables

* Refactor: extract offlineclient into its own file

* Refactor: move arbitraries into separate file

* Refactor: move playground requests into util function

* Fix: remove duplicate declaration of urlFriendlyString

* Chore: update comment

* Refactor: align on arbitrary generator naming

* Feat: Export single strategy arbitrary
This commit is contained in:
Thomas Heartman 2022-07-12 13:01:10 +02:00 committed by GitHub
parent 2a58b88ba3
commit 847119f964
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 2038 additions and 6 deletions

View File

@ -121,6 +121,7 @@
"serve-favicon": "^2.5.0",
"stoppable": "^1.1.0",
"type-is": "^1.6.18",
"unleash-client": "^3.15.0",
"unleash-frontend": "4.14.0-beta.0",
"uuid": "^8.3.2"
},
@ -155,6 +156,7 @@
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"faker": "5.5.3",
"fast-check": "^3.0.1",
"fetch-mock": "9.11.0",
"husky": "8.0.1",
"jest": "27.5.1",

View File

@ -11,5 +11,11 @@ export const endpointDescriptions = {
'Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.',
summary: 'Get all events related to a specific feature toggle.',
},
playground: {
description:
'Use the provided `context`, `environment`, and `projects` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.',
summary:
'Evaluate an Unleash context against a set of environments and projects.',
},
},
} as const;

View File

@ -1,4 +1,5 @@
import { OpenAPIV3 } from 'openapi-types';
import { addonParameterSchema } from './spec/addon-parameter-schema';
import { addonSchema } from './spec/addon-schema';
import { addonsSchema } from './spec/addons-schema';
@ -60,11 +61,15 @@ import { passwordSchema } from './spec/password-schema';
import { patchesSchema } from './spec/patches-schema';
import { patchSchema } from './spec/patch-schema';
import { permissionSchema } from './spec/permission-schema';
import { playgroundFeatureSchema } from './spec/playground-feature-schema';
import { playgroundResponseSchema } from './spec/playground-response-schema';
import { playgroundRequestSchema } from './spec/playground-request-schema';
import { projectEnvironmentSchema } from './spec/project-environment-schema';
import { projectSchema } from './spec/project-schema';
import { projectsSchema } from './spec/projects-schema';
import { resetPasswordSchema } from './spec/reset-password-schema';
import { roleSchema } from './spec/role-schema';
import { sdkContextSchema } from './spec/sdk-context-schema';
import { segmentSchema } from './spec/segment-schema';
import { sortOrderSchema } from './spec/sort-order-schema';
import { splashSchema } from './spec/splash-schema';
@ -93,6 +98,7 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema';
import { IServerOption } from '../types';
import { URL } from 'url';
@ -157,11 +163,15 @@ export const schemas = {
patchesSchema,
patchSchema,
permissionSchema,
playgroundFeatureSchema,
playgroundResponseSchema,
playgroundRequestSchema,
projectEnvironmentSchema,
projectSchema,
projectsSchema,
resetPasswordSchema,
roleSchema,
sdkContextSchema,
segmentSchema,
sortOrderSchema,
splashSchema,

View File

@ -0,0 +1,48 @@
import fc, { Arbitrary } from 'fast-check';
import { urlFriendlyString } from '../../../test/arbitraries.test';
import { validateSchema } from '../validate';
import {
playgroundFeatureSchema,
PlaygroundFeatureSchema,
} from './playground-feature-schema';
export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
fc.boolean().chain((isEnabled) =>
fc.record({
isEnabled: fc.constant(isEnabled),
projectId: urlFriendlyString(),
name: urlFriendlyString(),
variant: fc.record(
{
name: urlFriendlyString(),
enabled: fc.constant(isEnabled),
payload: fc.oneof(
fc.record({
type: fc.constant('json' as 'json'),
value: fc.json(),
}),
fc.record({
type: fc.constant('csv' as 'csv'),
value: fc
.array(fc.lorem())
.map((words) => words.join(',')),
}),
fc.record({
type: fc.constant('string' as 'string'),
value: fc.string(),
}),
),
},
{ requiredKeys: ['name', 'enabled'] },
),
}),
);
test('playgroundFeatureSchema', () =>
fc.assert(
fc.property(
generate(),
(data: PlaygroundFeatureSchema) =>
validateSchema(playgroundFeatureSchema.$id, data) === undefined,
),
));

View File

@ -0,0 +1,43 @@
import { FromSchema } from 'json-schema-to-ts';
export const playgroundFeatureSchema = {
$id: '#/components/schemas/playgroundFeatureSchema',
description:
'A simplified feature toggle model intended for the Unleash playground.',
type: 'object',
additionalProperties: false,
required: ['name', 'projectId', 'isEnabled', 'variant'],
properties: {
name: { type: 'string', examples: ['my-feature'] },
projectId: { type: 'string', examples: ['my-project'] },
isEnabled: { type: 'boolean', examples: [true] },
variant: {
type: 'object',
additionalProperties: false,
required: ['name', 'enabled'],
properties: {
name: { type: 'string' },
enabled: { type: 'boolean' },
payload: {
type: 'object',
additionalProperties: false,
required: ['type', 'value'],
properties: {
type: {
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: { type: 'string' },
},
},
},
nullable: true,
examples: ['green'],
},
},
components: { schemas: {} },
} as const;
export type PlaygroundFeatureSchema = FromSchema<
typeof playgroundFeatureSchema
>;

View File

@ -0,0 +1,32 @@
import fc, { Arbitrary } from 'fast-check';
import { urlFriendlyString } from '../../../test/arbitraries.test';
import {
playgroundRequestSchema,
PlaygroundRequestSchema,
} from '../../../lib/openapi/spec/playground-request-schema';
import { validateSchema } from '../validate';
import { generate as generateContext } from './sdk-context-schema.test';
export const generate = (): Arbitrary<PlaygroundRequestSchema> =>
fc.record({
environment: fc.oneof(
fc.constantFrom('development', 'production', 'default'),
fc.lorem({ maxCount: 1 }),
),
projects: fc.oneof(
fc.uniqueArray(
fc.oneof(fc.lorem({ maxCount: 1 }), urlFriendlyString()),
),
fc.constant('*' as '*'),
),
context: generateContext(),
});
test('playgroundRequestSchema', () =>
fc.assert(
fc.property(
generate(),
(data: PlaygroundRequestSchema) =>
validateSchema(playgroundRequestSchema.$id, data) === undefined,
),
));

View File

@ -0,0 +1,40 @@
import { FromSchema } from 'json-schema-to-ts';
import { ALL } from '../../types/models/api-token';
import { sdkContextSchema } from './sdk-context-schema';
export const playgroundRequestSchema = {
$id: '#/components/schemas/playgroundRequestSchema',
description: 'Data for the playground API to evaluate toggles',
type: 'object',
required: ['environment', 'context'],
properties: {
environment: { type: 'string', examples: ['development'] },
projects: {
oneOf: [
{
type: 'array',
items: { type: 'string' },
examples: ['my-project', 'my-other-project'],
description: 'A list of projects to check for toggles in.',
},
{
type: 'string',
enum: [ALL],
description: 'Check toggles in all projects.',
},
],
},
context: {
$ref: sdkContextSchema.$id,
},
},
components: {
schemas: {
sdkContextSchema,
},
},
} as const;
export type PlaygroundRequestSchema = FromSchema<
typeof playgroundRequestSchema
>;

View File

@ -0,0 +1,24 @@
import fc, { Arbitrary } from 'fast-check';
import {
playgroundResponseSchema,
PlaygroundResponseSchema,
} from '../../../lib/openapi/spec/playground-response-schema';
import { validateSchema } from '../validate';
import { generate as generateInput } from './playground-request-schema.test';
import { generate as generateToggles } from './playground-feature-schema.test';
const generate = (): Arbitrary<PlaygroundResponseSchema> =>
fc.record({
input: generateInput(),
features: fc.array(generateToggles()),
});
test('playgroundResponseSchema', () =>
fc.assert(
fc.property(
generate(),
(data: PlaygroundResponseSchema) =>
validateSchema(playgroundResponseSchema.$id, data) ===
undefined,
),
));

View File

@ -0,0 +1,34 @@
import { FromSchema } from 'json-schema-to-ts';
import { sdkContextSchema } from './sdk-context-schema';
import { playgroundRequestSchema } from './playground-request-schema';
import { playgroundFeatureSchema } from './playground-feature-schema';
export const playgroundResponseSchema = {
$id: '#/components/schemas/playgroundResponseSchema',
description: 'The state of all features given the provided input.',
type: 'object',
additionalProperties: false,
required: ['features', 'input'],
properties: {
input: {
$ref: playgroundRequestSchema.$id,
},
features: {
type: 'array',
items: {
$ref: playgroundFeatureSchema.$id,
},
},
},
components: {
schemas: {
sdkContextSchema,
playgroundRequestSchema,
playgroundFeatureSchema,
},
},
} as const;
export type PlaygroundResponseSchema = FromSchema<
typeof playgroundResponseSchema
>;

View File

@ -0,0 +1,27 @@
import fc, { Arbitrary } from 'fast-check';
import { validateSchema } from '../validate';
import { SdkContextSchema, sdkContextSchema } from './sdk-context-schema';
import { commonISOTimestamp } from '../../../test/arbitraries.test';
export const generate = (): Arbitrary<SdkContextSchema> =>
fc.record(
{
appName: fc.string({ minLength: 1 }),
currentTime: commonISOTimestamp(),
environment: fc.string(),
properties: fc.dictionary(fc.string(), fc.string()),
remoteAddress: fc.ipV4(),
sessionId: fc.uuid(),
userId: fc.emailAddress(),
},
{ requiredKeys: ['appName'] },
);
test('sdkContextSchema', () =>
fc.assert(
fc.property(
generate(),
(data: SdkContextSchema) =>
validateSchema(sdkContextSchema.$id, data) === undefined,
),
));

View File

@ -0,0 +1,47 @@
import { FromSchema } from 'json-schema-to-ts';
export const sdkContextSchema = {
$id: '#/components/schemas/sdkContextSchema',
description: 'The Unleash context as modeled in client SDKs',
type: 'object',
additionalProperties: {
type: 'string',
examples: ['top-level custom context value'],
},
required: ['appName'],
properties: {
appName: {
type: 'string',
minLength: 1,
examples: ['My cool application.'],
},
currentTime: {
type: 'string',
format: 'date-time',
examples: ['2022-07-05T12:56:41+02:00'],
},
environment: { type: 'string', deprecated: true },
properties: {
type: 'object',
additionalProperties: { type: 'string' },
examples: [
{
customContextField: 'this is one!',
otherCustomField: 3,
},
],
},
remoteAddress: {
type: 'string',
examples: ['192.168.1.1'],
},
sessionId: {
type: 'string',
examples: ['b65e7b23-fec0-4814-a129-0e9861ef18fc'],
},
userId: { type: 'string', examples: ['username@provider.com'] },
},
components: {},
} as const;
export type SdkContextSchema = FromSchema<typeof sdkContextSchema>;

View File

@ -7,6 +7,7 @@ import { FeatureTypeController } from './feature-type';
import ArchiveController from './archive';
import StrategyController from './strategy';
import EventController from './event';
import PlaygroundController from './playground';
import MetricsController from './metrics';
import UserController from './user';
import ConfigController from './config';
@ -50,6 +51,10 @@ class AdminApi extends Controller {
new StrategyController(config, services).router,
);
this.app.use('/events', new EventController(config, services).router);
this.app.use(
'/playground',
new PlaygroundController(config, services).router,
);
this.app.use(
'/metrics',
new MetricsController(config, services).router,

View File

@ -0,0 +1,88 @@
import fc from 'fast-check';
import supertest from 'supertest';
import { createServices } from '../../services';
import { createTestConfig } from '../../../test/config/test-config';
import createStores from '../../../test/fixtures/store';
import getApp from '../../app';
import {
playgroundRequestSchema,
PlaygroundRequestSchema,
} from '../../../lib/openapi/spec/playground-request-schema';
import { generate as generateRequest } from '../../../lib/openapi/spec/playground-request-schema.test';
import { clientFeatures } from '../../../test/arbitraries.test';
async function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = createStores();
const config = createTestConfig({
server: { baseUriPath: base },
});
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
return { base, request: supertest(app) };
}
describe('toggle generator', () => {
it('generates toggles with unique names', () => {
fc.assert(
fc.property(
clientFeatures({ minLength: 2 }),
(toggles) =>
toggles.length ===
[...new Set(toggles.map((feature) => feature.name))].length,
),
);
});
});
const testParams = {
interruptAfterTimeLimit: 4000, // Default timeout in Jest is 5000ms
markInterruptAsFailure: false, // When set to false, timeout during initial cases will not be considered as a failure
};
describe('the playground API', () => {
test('should return the provided input arguments as part of the response', async () => {
await fc.assert(
fc.asyncProperty(
generateRequest(),
async (payload: PlaygroundRequestSchema) => {
const { request, base } = await getSetup();
const { body } = await request
.post(`${base}/api/admin/playground`)
.send(payload)
.expect('Content-Type', /json/)
.expect(200);
expect(body.input).toStrictEqual(payload);
return true;
},
),
testParams,
);
});
test('should return 400 if any of the required query properties are missing', async () => {
await fc.assert(
fc.asyncProperty(
generateRequest(),
fc.constantFrom(...playgroundRequestSchema.required),
async (payload, requiredKey) => {
const { request, base } = await getSetup();
delete payload[requiredKey];
const { status } = await request
.post(`${base}/api/admin/playground`)
.send(payload)
.expect('Content-Type', /json/);
return status === 400;
},
),
testParams,
);
});
});

View File

@ -0,0 +1,74 @@
import { Request, Response } from 'express';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
import { NONE } from '../../types/permissions';
import Controller from '../controller';
import { OpenApiService } from '../../services/openapi-service';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import { endpointDescriptions } from '../../openapi/endpoint-descriptions';
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
import { createRequestSchema } from '../../../lib/openapi/util/create-request-schema';
import {
PlaygroundResponseSchema,
playgroundResponseSchema,
} from '../../../lib/openapi/spec/playground-response-schema';
import { PlaygroundRequestSchema } from '../../../lib/openapi/spec/playground-request-schema';
import { PlaygroundService } from '../../../lib/services/playground-service';
export default class PlaygroundController extends Controller {
private openApiService: OpenApiService;
private playgroundService: PlaygroundService;
constructor(
config: IUnleashConfig,
{
openApiService,
playgroundService,
}: Pick<IUnleashServices, 'openApiService' | 'playgroundService'>,
) {
super(config);
this.openApiService = openApiService;
this.playgroundService = playgroundService;
this.route({
method: 'post',
path: '',
handler: this.evaluateContext,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getPlayground',
tags: ['admin'],
responses: {
...getStandardResponses(400, 401),
200: createResponseSchema('playgroundResponseSchema'),
},
requestBody: createRequestSchema('playgroundRequestSchema'),
...endpointDescriptions.admin.playground,
}),
],
});
}
async evaluateContext(
req: Request<any, any, PlaygroundRequestSchema>,
res: Response<PlaygroundResponseSchema>,
): Promise<void> {
const response: PlaygroundResponseSchema = {
input: req.body,
features: await this.playgroundService.evaluateQuery(
req.body.projects,
req.body.environment,
req.body.context,
),
};
this.openApiService.respondWithValidation(
200,
res,
playgroundResponseSchema.$id,
response,
);
}
}

View File

@ -31,6 +31,7 @@ import UserSplashService from './user-splash-service';
import { SegmentService } from './segment-service';
import { OpenApiService } from './openapi-service';
import { ClientSpecService } from './client-spec-service';
import { PlaygroundService } from './playground-service';
export const createServices = (
stores: IUnleashStores,
@ -84,6 +85,9 @@ export const createServices = (
const userSplashService = new UserSplashService(stores, config);
const openApiService = new OpenApiService(config);
const clientSpecService = new ClientSpecService(config);
const playgroundService = new PlaygroundService(config, {
featureToggleServiceV2,
});
return {
accessService,
@ -116,6 +120,7 @@ export const createServices = (
segmentService,
openApiService,
clientSpecService,
playgroundService,
};
};

View File

@ -0,0 +1,70 @@
import FeatureToggleService from './feature-toggle-service';
import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema';
import { IUnleashServices } from 'lib/types/services';
import { ALL } from '../../lib/types/models/api-token';
import { PlaygroundFeatureSchema } from 'lib/openapi/spec/playground-feature-schema';
import { Logger } from '../logger';
import { IUnleashConfig } from 'lib/types';
import { offlineUnleashClient } from '..//util/offline-unleash-client';
export class PlaygroundService {
private readonly logger: Logger;
private readonly featureToggleService: FeatureToggleService;
constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
}: Pick<IUnleashServices, 'featureToggleServiceV2'>,
) {
this.logger = config.getLogger('services/playground-service.ts');
this.featureToggleService = featureToggleServiceV2;
}
async evaluateQuery(
projects: typeof ALL | string[],
environment: string,
context: SdkContextSchema,
): Promise<PlaygroundFeatureSchema[]> {
const toggles = await this.featureToggleService.getClientFeatures({
project: projects === ALL ? undefined : projects,
environment,
});
const [head, ...rest] = toggles;
if (!head) {
return [];
} else {
const client = await offlineUnleashClient(
[head, ...rest],
context,
this.logger.error,
);
const clientContext = {
...context,
currentTime: context.currentTime
? new Date(context.currentTime)
: undefined,
};
const output: PlaygroundFeatureSchema[] = await Promise.all(
client.getFeatureToggleDefinitions().map(async (feature) => {
return {
isEnabled: client.isEnabled(
feature.name,
clientContext,
),
projectId: await this.featureToggleService.getProjectId(
feature.name,
),
variant: client.getVariant(feature.name),
name: feature.name,
};
}),
);
return output;
}
}
}

View File

@ -1,4 +0,0 @@
// Create a string with allowed values from a values array. ['A', 'B'] => 'A' | 'B'
export type AllowedStrings<T extends ReadonlyArray<unknown>> =
// eslint-disable-next-line @typescript-eslint/no-shadow
T extends ReadonlyArray<infer AllowedStrings> ? AllowedStrings : never;

View File

@ -3,9 +3,8 @@ import { LogProvider } from '../logger';
import { IRole } from './stores/access-store';
import { IUser } from './user';
import { ALL_OPERATORS } from '../util/constants';
import { AllowedStrings } from './allowed-strings';
export type Operator = AllowedStrings<typeof ALL_OPERATORS>;
export type Operator = typeof ALL_OPERATORS[number];
export interface IConstraint {
contextName: string;

View File

@ -27,6 +27,7 @@ import UserSplashService from '../services/user-splash-service';
import { SegmentService } from '../services/segment-service';
import { OpenApiService } from '../services/openapi-service';
import { ClientSpecService } from '../services/client-spec-service';
import { PlaygroundService } from 'lib/services/playground-service';
export interface IUnleashServices {
accessService: AccessService;
@ -59,4 +60,5 @@ export interface IUnleashServices {
segmentService: SegmentService;
openApiService: OpenApiService;
clientSpecService: ClientSpecService;
playgroundService: PlaygroundService;
}

View File

@ -0,0 +1,205 @@
import { offlineUnleashClient } from './offline-unleash-client';
describe('offline client', () => {
it('considers enabled variants with a default strategy to be on', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
{
name,
enabled: true,
strategies: [{ name: 'default' }],
variants: [],
type: '',
stale: false,
},
],
{ appName: 'other-app', environment: 'default' },
console.log,
);
expect(client.isEnabled(name)).toBeTruthy();
});
it('constrains on appName', async () => {
const enabledFeature = 'toggle-name';
const disabledFeature = 'other-toggle';
const appName = 'app-name';
const client = await offlineUnleashClient(
[
{
name: enabledFeature,
enabled: true,
strategies: [
{
name: 'default',
constraints: [
{
contextName: 'appName',
operator: 'IN',
values: [appName],
},
],
},
],
variants: [],
type: '',
stale: false,
},
{
name: disabledFeature,
enabled: true,
strategies: [
{
name: 'default',
constraints: [
{
contextName: 'appName',
operator: 'IN',
values: ['otherApp'],
},
],
},
],
variants: [],
type: '',
stale: false,
},
],
{ appName, environment: 'default' },
console.log,
);
expect(client.isEnabled(enabledFeature)).toBeTruthy();
expect(client.isEnabled(disabledFeature)).toBeFalsy();
});
it('considers disabled variants with a default strategy to be off', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
{
strategies: [
{
name: 'default',
},
],
stale: false,
enabled: false,
name,
type: 'experiment',
variants: [],
},
],
{ appName: 'client-test' },
console.log,
);
expect(client.isEnabled(name)).toBeFalsy();
});
it('considers disabled variants with a default strategy and variants to be off', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
{
strategies: [
{
name: 'default',
},
],
stale: false,
enabled: false,
name,
type: 'experiment',
variants: [
{
name: 'a',
weight: 500,
weightType: 'variable',
stickiness: 'default',
overrides: [],
},
{
name: 'b',
weight: 500,
weightType: 'variable',
stickiness: 'default',
overrides: [],
},
],
},
],
{ appName: 'client-test' },
console.log,
);
expect(client.isEnabled(name)).toBeFalsy();
});
it("returns variant {name: 'disabled', enabled: false } if the toggle isn't enabled", async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
{
strategies: [],
stale: false,
enabled: false,
name,
type: 'experiment',
variants: [
{
name: 'a',
weight: 500,
weightType: 'variable',
stickiness: 'default',
overrides: [],
},
{
name: 'b',
weight: 500,
weightType: 'variable',
stickiness: 'default',
overrides: [],
},
],
},
],
{ appName: 'client-test' },
console.log,
);
expect(client.isEnabled(name)).toBeFalsy();
expect(client.getVariant(name).name).toEqual('disabled');
expect(client.getVariant(name).enabled).toBeFalsy();
});
it('returns the disabled variant if there are no variants', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient(
[
{
strategies: [
{
name: 'default',
constraints: [],
},
],
stale: false,
enabled: true,
name,
type: 'experiment',
variants: [],
},
],
{ appName: 'client-test' },
console.log,
);
expect(client.getVariant(name, {}).name).toEqual('disabled');
expect(client.getVariant(name, {}).enabled).toBeFalsy();
expect(client.isEnabled(name, {})).toBeTruthy();
});
});

View File

@ -0,0 +1,62 @@
import { SdkContextSchema } from 'lib/openapi/spec/sdk-context-schema';
import { InMemStorageProvider, Unleash as UnleashClient } from 'unleash-client';
import { FeatureConfigurationClient } from 'lib/types/stores/feature-strategies-store';
import { Operator } from 'unleash-client/lib/strategy/strategy';
import { once } from 'events';
enum PayloadType {
STRING = 'string',
}
type NonEmptyList<T> = [T, ...T[]];
const mapFeaturesForBootstrap = (features: FeatureConfigurationClient[]) =>
features.map((feature) => ({
impressionData: false,
...feature,
variants: feature.variants.map((variant) => ({
overrides: [],
...variant,
payload: variant.payload && {
...variant.payload,
type: variant.payload.type as unknown as PayloadType,
},
})),
strategies: feature.strategies.map((strategy) => ({
parameters: {},
...strategy,
constraints:
strategy.constraints &&
strategy.constraints.map((constraint) => ({
inverted: false,
values: [],
...constraint,
operator: constraint.operator as unknown as Operator,
})),
})),
}));
export const offlineUnleashClient = async (
features: NonEmptyList<FeatureConfigurationClient>,
context: SdkContextSchema,
logError: (message: any, ...args: any[]) => void,
): Promise<UnleashClient> => {
const client = new UnleashClient({
...context,
appName: context.appName,
disableMetrics: true,
refreshInterval: 0,
url: 'not-needed',
storageProvider: new InMemStorageProvider(),
bootstrap: {
data: mapFeaturesForBootstrap(features),
},
});
client.on('error', logError);
client.start();
await once(client, 'ready');
return client;
};

View File

@ -0,0 +1,159 @@
import fc, { Arbitrary } from 'fast-check';
import { ALL_OPERATORS } from '../lib/util/constants';
import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema';
import { WeightType } from '../lib/types/model';
import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema';
import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema';
export const urlFriendlyString = (): Arbitrary<string> =>
fc
.array(
fc.oneof(
fc.integer({ min: 0x30, max: 0x39 }).map(String.fromCharCode), // numbers
fc.integer({ min: 0x41, max: 0x5a }).map(String.fromCharCode), // UPPERCASE LETTERS
fc.integer({ min: 0x61, max: 0x7a }).map(String.fromCharCode), // lowercase letters
fc.constantFrom('-', '_', '~', '.'), // rest
fc.lorem({ maxCount: 1 }), // random words for more 'realistic' names
),
{ minLength: 1 },
)
.map((arr) => arr.join(''));
export const commonISOTimestamp = (): Arbitrary<string> =>
fc
.date({
min: new Date('1900-01-01T00:00:00.000Z'),
max: new Date('9999-12-31T23:59:59.999Z'),
})
.map((timestamp) => timestamp.toISOString());
const strategyConstraints = (): Arbitrary<ConstraintSchema[]> =>
fc.array(
fc.record({
contextName: urlFriendlyString(),
operator: fc.constantFrom(...ALL_OPERATORS),
caseInsensitive: fc.boolean(),
inverted: fc.boolean(),
values: fc.array(fc.string()),
value: fc.string(),
}),
);
export const strategy = (
name: string,
parameters: Arbitrary<Record<string, string>>,
): Arbitrary<FeatureStrategySchema> =>
fc.record({
name: fc.constant(name),
parameters,
constraints: strategyConstraints(),
});
export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
fc.array(
fc.oneof(
strategy('default', fc.constant({})),
strategy(
'flexibleRollout',
fc.record({
groupId: fc.lorem({ maxCount: 1 }),
rollout: fc.nat({ max: 100 }).map(String),
stickiness: fc.constantFrom(
'default',
'userId',
'sessionId',
),
}),
),
strategy(
'applicationHostname',
fc.record({
hostNames: fc
.uniqueArray(fc.domain())
.map((domains) => domains.join(',')),
}),
),
strategy(
'userWithId',
fc.record({
userIds: fc
.uniqueArray(fc.emailAddress())
.map((ids) => ids.join(',')),
}),
),
strategy(
'remoteAddress',
fc.record({
IPs: fc.uniqueArray(fc.ipV4()).map((ips) => ips.join(',')),
}),
),
),
);
export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
fc.record(
{
name: name ? fc.constant(name) : urlFriendlyString(),
type: fc.constantFrom(
'release',
'kill-switch',
'experiment',
'operational',
'permission',
),
description: fc.lorem(),
project: urlFriendlyString(),
enabled: fc.boolean(),
createdAt: commonISOTimestamp(),
lastSeenAt: commonISOTimestamp(),
stale: fc.boolean(),
impressionData: fc.option(fc.boolean()),
strategies: strategies(),
variants: fc.array(
fc.record({
name: urlFriendlyString(),
weight: fc.nat({ max: 1000 }),
weightType: fc.constant(WeightType.VARIABLE),
stickiness: fc.constant('default'),
payload: fc.option(
fc.oneof(
fc.record({
type: fc.constant('json'),
value: fc.json(),
}),
fc.record({
type: fc.constant('csv'),
value: fc
.array(fc.lorem())
.map((words) => words.join(',')),
}),
fc.record({
type: fc.constant('string'),
value: fc.string(),
}),
),
),
}),
),
},
{ requiredKeys: ['name', 'enabled', 'project', 'strategies'] },
);
export const clientFeatures = (constraints?: {
minLength?: number;
}): Arbitrary<ClientFeatureSchema[]> =>
fc.uniqueArray(clientFeature(), {
...constraints,
selector: (v) => v.name,
});
// TEST ARBITRARIES
test('url-friendly strings are URL-friendly', () =>
fc.assert(
fc.property(urlFriendlyString(), (input: string) =>
/^[\w~.-]+$/.test(input),
),
));

View File

@ -0,0 +1,580 @@
import fc, { Arbitrary } from 'fast-check';
import { clientFeature, clientFeatures } from '../../../arbitraries.test';
import { generate as generateRequest } from '../../../../lib/openapi/spec/playground-request-schema.test';
import dbInit, { ITestDb } from '../../helpers/database-init';
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
import { FeatureToggle, WeightType } from '../../../../lib/types/model';
import getLogger from '../../../fixtures/no-logger';
import {
ApiTokenType,
IApiToken,
} from '../../../../lib/types/models/api-token';
import { PlaygroundFeatureSchema } from 'lib/openapi/spec/playground-feature-schema';
import { ClientFeatureSchema } from 'lib/openapi/spec/client-feature-schema';
import { PlaygroundResponseSchema } from 'lib/openapi/spec/playground-response-schema';
import { PlaygroundRequestSchema } from 'lib/openapi/spec/playground-request-schema';
let app: IUnleashTest;
let db: ITestDb;
let token: IApiToken;
beforeAll(async () => {
db = await dbInit('playground_api_serial', getLogger);
app = await setupAppWithAuth(db.stores);
const { apiTokenService } = app.services;
token = await apiTokenService.createApiTokenWithProjects({
type: ApiTokenType.ADMIN,
username: 'tester',
environment: '*',
projects: ['*'],
});
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
const reset = (database: ITestDb) => async () => {
await database.stores.featureToggleStore.deleteAll();
await database.stores.environmentStore.deleteAll();
};
const toArray = <T>(x: T): [T] => [x];
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 playgroundRequest = async (
testApp: IUnleashTest,
secret: string,
request: PlaygroundRequestSchema,
): Promise<PlaygroundResponseSchema> => {
const {
body,
}: {
body: PlaygroundResponseSchema;
} = 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 = (
database: ITestDb,
features: ClientFeatureSchema[],
environment: string,
): Promise<FeatureToggle[]> =>
Promise.all(
features.map(async (feature) => {
// create feature
const toggle = await database.stores.featureToggleStore.create(
feature.project,
{
...feature,
createdAt: undefined,
variants: [
...(feature.variants ?? []).map((variant) => ({
...variant,
weightType: WeightType.VARIABLE,
stickiness: 'default',
})),
],
},
);
// 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.
});
// assign strategies
await Promise.all(
(feature.strategies || []).map((strategy) =>
database.stores.featureStrategiesStore.createStrategyFeatureEnv(
{
parameters: {},
constraints: [],
...strategy,
featureName: feature.name,
environment,
strategyName: strategy.name,
projectId: feature.project,
},
),
),
);
// enable/disable the feature in environment
await database.stores.featureEnvironmentStore.addEnvironmentToFeature(
feature.name,
environment,
feature.enabled,
);
return toggle;
}),
);
test('Returned features should be a subset of the provided toggles', 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,
);
});
test('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('*' as '*'),
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 '*':
// 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,
);
});
test('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: '*',
environment: 'default',
context: {
appName: 'playground-test',
},
},
);
const createDict = (xs: { name: string }[]) =>
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 toggles (${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: PlaygroundFeatureSchema =
mappedToggles[feature.name];
expect(mapped).toBeTruthy();
return (
feature.name === mapped.name &&
feature.project === mapped.projectId
);
});
},
)
.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 = (): Arbitrary<ClientFeatureSchema[]> =>
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' as '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',
},
})),
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]:
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' as 'IN'),
}),
fc.record({
name: fc.constant('sessionId'),
value: fc.uuid(),
operator: fc.constant('IN' as 'IN'),
}),
fc.record({
name: fc.constant('userId'),
value: fc.emailAddress(),
operator: fc.constant('IN' as 'IN'),
}),
);
const constrainedFeatures = (): Arbitrary<ClientFeatureSchema[]> =>
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 = (): Arbitrary<ClientFeatureSchema[]> =>
fc.uniqueArray(
fc
.tuple(
clientFeature(),
contextValue().map((context) => ({
name: 'default',
constraints: [
{
values: [context.value],
inverted: false,
operator: 'IN' as '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,
);
});
});
});

View File

@ -1746,6 +1746,139 @@ Object {
],
"type": "object",
},
"playgroundFeatureSchema": Object {
"additionalProperties": false,
"description": "A simplified feature toggle model intended for the Unleash playground.",
"properties": Object {
"isEnabled": Object {
"examples": Array [
true,
],
"type": "boolean",
},
"name": Object {
"examples": Array [
"my-feature",
],
"type": "string",
},
"projectId": Object {
"examples": Array [
"my-project",
],
"type": "string",
},
"variant": Object {
"additionalProperties": false,
"examples": Array [
"green",
],
"nullable": true,
"properties": Object {
"enabled": Object {
"type": "boolean",
},
"name": Object {
"type": "string",
},
"payload": Object {
"additionalProperties": false,
"properties": Object {
"type": Object {
"enum": Array [
"json",
"csv",
"string",
],
"type": "string",
},
"value": Object {
"type": "string",
},
},
"required": Array [
"type",
"value",
],
"type": "object",
},
},
"required": Array [
"name",
"enabled",
],
"type": "object",
},
},
"required": Array [
"name",
"projectId",
"isEnabled",
"variant",
],
"type": "object",
},
"playgroundRequestSchema": Object {
"description": "Data for the playground API to evaluate toggles",
"properties": Object {
"context": Object {
"$ref": "#/components/schemas/sdkContextSchema",
},
"environment": Object {
"examples": Array [
"development",
],
"type": "string",
},
"projects": Object {
"oneOf": Array [
Object {
"description": "A list of projects to check for toggles in.",
"examples": Array [
"my-project",
"my-other-project",
],
"items": Object {
"type": "string",
},
"type": "array",
},
Object {
"description": "Check toggles in all projects.",
"enum": Array [
"*",
],
"type": "string",
},
],
},
},
"required": Array [
"environment",
"context",
],
"type": "object",
},
"playgroundResponseSchema": Object {
"additionalProperties": false,
"description": "The state of all features given the provided input.",
"properties": Object {
"features": Object {
"items": Object {
"$ref": "#/components/schemas/playgroundFeatureSchema",
},
"type": "array",
},
"input": Object {
"$ref": "#/components/schemas/playgroundRequestSchema",
},
},
"required": Array [
"features",
"input",
],
"type": "object",
},
"projectEnvironmentSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -1849,6 +1982,69 @@ Object {
],
"type": "object",
},
"sdkContextSchema": Object {
"additionalProperties": Object {
"examples": Array [
"top-level custom context value",
],
"type": "string",
},
"description": "The Unleash context as modeled in client SDKs",
"properties": Object {
"appName": Object {
"examples": Array [
"My cool application.",
],
"minLength": 1,
"type": "string",
},
"currentTime": Object {
"examples": Array [
"2022-07-05T12:56:41+02:00",
],
"format": "date-time",
"type": "string",
},
"environment": Object {
"deprecated": true,
"type": "string",
},
"properties": Object {
"additionalProperties": Object {
"type": "string",
},
"examples": Array [
Object {
"customContextField": "this is one!",
"otherCustomField": 3,
},
],
"type": "object",
},
"remoteAddress": Object {
"examples": Array [
"192.168.1.1",
],
"type": "string",
},
"sessionId": Object {
"examples": Array [
"b65e7b23-fec0-4814-a129-0e9861ef18fc",
],
"type": "string",
},
"userId": Object {
"examples": Array [
"username@provider.com",
],
"type": "string",
},
},
"required": Array [
"appName",
],
"type": "object",
},
"segmentSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -3701,6 +3897,45 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/playground": Object {
"post": Object {
"description": "Use the provided \`context\`, \`environment\`, and \`projects\` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.",
"operationId": "getPlayground",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/playgroundRequestSchema",
},
},
},
"description": "playgroundRequestSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/playgroundResponseSchema",
},
},
},
"description": "playgroundResponseSchema",
},
"400": Object {
"description": "The request data does not match what we expect.",
},
"401": Object {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
},
"summary": "Evaluate an Unleash context against a set of environments and projects.",
"tags": Array [
"admin",
],
},
},
"/api/admin/projects": Object {
"get": Object {
"operationId": "getProjects",

View File

@ -0,0 +1,157 @@
import { PlaygroundService } from '../../../lib/services/playground-service';
import { clientFeatures } from '../../arbitraries.test';
import { generate as generateContext } from '../../../lib/openapi/spec/sdk-context-schema.test';
import fc from 'fast-check';
import { createTestConfig } from '../../config/test-config';
import dbInit, { ITestDb } from '../helpers/database-init';
import { IUnleashStores } from '../../../lib/types/stores';
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import { SegmentService } from '../../../lib/services/segment-service';
import { WeightType } from '../../../lib/types/model';
import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema';
import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client';
let stores: IUnleashStores;
let db: ITestDb;
let service: PlaygroundService;
let featureToggleService: FeatureToggleService;
beforeAll(async () => {
const config = createTestConfig();
db = await dbInit('playground_service_serial', config.getLogger);
stores = db.stores;
featureToggleService = new FeatureToggleService(
stores,
config,
new SegmentService(stores, config),
);
service = new PlaygroundService(config, {
featureToggleServiceV2: featureToggleService,
});
});
afterAll(async () => {
await db.destroy();
});
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
};
describe('the playground service (e2e)', () => {
const isDisabledVariant = ({
name,
enabled,
}: {
name: string;
enabled: boolean;
}) => name === 'disabled' && !enabled;
test('should return the same enabled toggles as the raw SDK correctly mapped', async () => {
await fc.assert(
fc
.asyncProperty(
clientFeatures({ minLength: 1 }),
generateContext(),
async (toggles, context) => {
await Promise.all(
toggles.map((feature) =>
stores.featureToggleStore.create(
feature.project,
{
...feature,
createdAt: undefined,
variants: [
...(feature.variants ?? []).map(
(variant) => ({
...variant,
weightType:
WeightType.VARIABLE,
stickiness: 'default',
}),
),
],
},
),
),
);
const projects = '*';
const env = 'default';
const serviceToggles: PlaygroundFeatureSchema[] =
await service.evaluateQuery(projects, env, context);
const [head, ...rest] =
await featureToggleService.getClientFeatures();
if (!head) {
return serviceToggles.length === 0;
}
const client = await offlineUnleashClient(
[head, ...rest],
context,
console.log,
);
const clientContext = {
...context,
currentTime: context.currentTime
? new Date(context.currentTime)
: undefined,
};
return serviceToggles.every((feature) => {
const enabledStateMatches =
feature.isEnabled ===
client.isEnabled(feature.name, clientContext);
// if x.isEnabled then variant should === variant.name. Otherwise it should be null
// if x is disabled, then the variant will be the
// disabled variant.
if (!feature.isEnabled) {
return (
enabledStateMatches &&
isDisabledVariant(feature.variant)
);
}
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 (
feature.isEnabled &&
isDisabledVariant(feature.variant)
) {
return (
enabledStateMatches &&
isDisabledVariant(clientVariant)
);
}
return (
enabledStateMatches &&
clientVariant.name === feature.variant.name &&
clientVariant.enabled ===
feature.variant.enabled &&
clientVariant.payload ===
feature.variant.payload
);
});
},
)
.afterEach(async () => {
await stores.featureToggleStore.deleteAll();
}),
testParams,
);
});
});

View File

@ -1860,6 +1860,30 @@ cacache@^16.0.2:
tar "^6.1.11"
unique-filename "^1.1.1"
cacache@^16.1.0:
version "16.1.1"
resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.1.tgz#4e79fb91d3efffe0630d5ad32db55cc1b870669c"
integrity sha512-VDKN+LHyCQXaaYZ7rA/qtkURU+/yYhviUdvqEv2LT6QPZU8jpyzEkEVAcKlKLt5dJ5BRp11ym8lo3NKLluEPLg==
dependencies:
"@npmcli/fs" "^2.1.0"
"@npmcli/move-file" "^2.0.0"
chownr "^2.0.0"
fs-minipass "^2.1.0"
glob "^8.0.1"
infer-owner "^1.0.4"
lru-cache "^7.7.1"
minipass "^3.1.6"
minipass-collect "^1.0.2"
minipass-flush "^1.0.5"
minipass-pipeline "^1.2.4"
mkdirp "^1.0.4"
p-map "^4.0.0"
promise-inflight "^1.0.1"
rimraf "^3.0.2"
ssri "^9.0.0"
tar "^6.1.11"
unique-filename "^1.1.1"
cache-base@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz"
@ -3188,6 +3212,13 @@ faker@5.5.3:
resolved "https://registry.npmjs.org/faker/-/faker-5.5.3.tgz"
integrity sha512-wLTv2a28wjUyWkbnX7u/ABZBkUkIF2fCd73V6P2oFqEGEktDfzWx4UxrSqtPRw0xPRAcjeAOIiJWqZm3pP4u3g==
fast-check@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.0.1.tgz#b9e7b57c4643a4e62893aca85e21c270591d0eac"
integrity sha512-AriFDYpYVOBynpPZq/quxSLumFOo2hPB2H5Nz2vc1QlNfjOaA62zX8USNXcOY5nwKHEq7lZ84dG9M1W+LAND1g==
dependencies:
pure-rand "^5.0.1"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
@ -5239,6 +5270,28 @@ make-error@1.x, make-error@^1.1.1:
resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
make-fetch-happen@^10.0.0:
version "10.1.8"
resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.1.8.tgz#3b6e93dd8d8fdb76c0d7bf32e617f37c3108435a"
integrity sha512-0ASJbG12Au6+N5I84W+8FhGS6iM8MyzvZady+zaQAu+6IOaESFzCLLD0AR1sAFF3Jufi8bxm586ABN6hWd3k7g==
dependencies:
agentkeepalive "^4.2.1"
cacache "^16.1.0"
http-cache-semantics "^4.1.0"
http-proxy-agent "^5.0.0"
https-proxy-agent "^5.0.0"
is-lambda "^1.0.1"
lru-cache "^7.7.1"
minipass "^3.1.6"
minipass-collect "^1.0.2"
minipass-fetch "^2.0.3"
minipass-flush "^1.0.5"
minipass-pipeline "^1.2.4"
negotiator "^0.6.3"
promise-retry "^2.0.1"
socks-proxy-agent "^7.0.0"
ssri "^9.0.0"
make-fetch-happen@^10.1.2:
version "10.1.2"
resolved "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.1.2.tgz"
@ -5612,6 +5665,11 @@ multer@^1.4.5-lts.1:
type-is "^1.6.4"
xtend "^4.0.0"
murmurhash3js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/murmurhash3js/-/murmurhash3js-3.0.1.tgz#3e983e5b47c2a06f43a713174e7e435ca044b998"
integrity sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow==
mustache@^4.1.0:
version "4.2.0"
resolved "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz"
@ -6360,6 +6418,11 @@ punycode@^2.1.0, punycode@^2.1.1:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
pure-rand@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-5.0.1.tgz#97a287b4b4960b2a3448c0932bf28f2405cac51d"
integrity sha512-ksWccjmXOHU2gJBnH0cK1lSYdvSZ0zLoCMSz/nTGh6hDvCSgcRxDyIcOBD6KNxFz3xhMPm/T267Tbe2JRymKEQ==
qs@6.7.0:
version "6.7.0"
resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz"
@ -6948,6 +7011,15 @@ socks-proxy-agent@^6.1.1:
debug "^4.3.3"
socks "^2.6.2"
socks-proxy-agent@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6"
integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==
dependencies:
agent-base "^6.0.2"
debug "^4.3.3"
socks "^2.6.2"
socks@^2.6.2:
version "2.6.2"
resolved "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz"
@ -7770,6 +7842,16 @@ universalify@^2.0.0:
resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz"
integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
unleash-client@^3.15.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.15.0.tgz#6ba4d917a0d8d628e73267ae8114d261d210a1a9"
integrity sha512-pNfzJa7QWhtSMTGNhmanpgqjg3xIJK4gJgQiZdkJlUY6GPDXit8p4fGs94jC8zM/xzpa1ji9+sSx6GC9YDeCiQ==
dependencies:
ip "^1.1.5"
make-fetch-happen "^10.0.0"
murmurhash3js "^3.0.1"
semver "^7.3.5"
unleash-frontend@4.14.0-beta.0:
version "4.14.0-beta.0"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.14.0-beta.0.tgz#c68335f92f92494bdd25eb3aeb5f2dd9ce7950de"