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:
parent
2a58b88ba3
commit
847119f964
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
48
src/lib/openapi/spec/playground-feature-schema.test.ts
Normal file
48
src/lib/openapi/spec/playground-feature-schema.test.ts
Normal 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,
|
||||
),
|
||||
));
|
43
src/lib/openapi/spec/playground-feature-schema.ts
Normal file
43
src/lib/openapi/spec/playground-feature-schema.ts
Normal 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
|
||||
>;
|
32
src/lib/openapi/spec/playground-request-schema.test.ts
Normal file
32
src/lib/openapi/spec/playground-request-schema.test.ts
Normal 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,
|
||||
),
|
||||
));
|
40
src/lib/openapi/spec/playground-request-schema.ts
Normal file
40
src/lib/openapi/spec/playground-request-schema.ts
Normal 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
|
||||
>;
|
24
src/lib/openapi/spec/playground-response-schema.test.ts
Normal file
24
src/lib/openapi/spec/playground-response-schema.test.ts
Normal 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,
|
||||
),
|
||||
));
|
34
src/lib/openapi/spec/playground-response-schema.ts
Normal file
34
src/lib/openapi/spec/playground-response-schema.ts
Normal 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
|
||||
>;
|
27
src/lib/openapi/spec/sdk-context-schema.test.ts
Normal file
27
src/lib/openapi/spec/sdk-context-schema.test.ts
Normal 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,
|
||||
),
|
||||
));
|
47
src/lib/openapi/spec/sdk-context-schema.ts
Normal file
47
src/lib/openapi/spec/sdk-context-schema.ts
Normal 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>;
|
@ -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,
|
||||
|
88
src/lib/routes/admin-api/playground.test.ts
Normal file
88
src/lib/routes/admin-api/playground.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
74
src/lib/routes/admin-api/playground.ts
Normal file
74
src/lib/routes/admin-api/playground.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
70
src/lib/services/playground-service.ts
Normal file
70
src/lib/services/playground-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
205
src/lib/util/offline-unleash-client.test.ts
Normal file
205
src/lib/util/offline-unleash-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
62
src/lib/util/offline-unleash-client.ts
Normal file
62
src/lib/util/offline-unleash-client.ts
Normal 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;
|
||||
};
|
159
src/test/arbitraries.test.ts
Normal file
159
src/test/arbitraries.test.ts
Normal 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),
|
||||
),
|
||||
));
|
580
src/test/e2e/api/admin/playground.e2e.test.ts
Normal file
580
src/test/e2e/api/admin/playground.e2e.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -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",
|
||||
|
157
src/test/e2e/services/playground-service.test.ts
Normal file
157
src/test/e2e/services/playground-service.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
82
yarn.lock
82
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user