1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-06 00:07:44 +01:00
unleash.unleash/src/test/e2e/api/admin/playground.e2e.test.ts
Thomas Heartman 847119f964
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
2022-07-12 13:01:10 +02:00

581 lines
23 KiB
TypeScript

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,
);
});
});
});