mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: add all feature variants to the playground payload (#1835)
* Chore: extract variant creation arbitrary. * Feat: add "variants" as a required property on playground response * Wip: fix up some schema generation * Fix remaining openapi schemas * Fix: add missing variants property * Feat: test for variants * Feat: add `variants` property to playground response * Chore: update openapi snapshot
This commit is contained in:
parent
7a7b86d440
commit
012da8469f
@ -1,5 +1,5 @@
|
|||||||
import fc, { Arbitrary } from 'fast-check';
|
import fc, { Arbitrary } from 'fast-check';
|
||||||
import { urlFriendlyString } from '../../../test/arbitraries.test';
|
import { urlFriendlyString, variants } from '../../../test/arbitraries.test';
|
||||||
import { validateSchema } from '../validate';
|
import { validateSchema } from '../validate';
|
||||||
import {
|
import {
|
||||||
playgroundFeatureSchema,
|
playgroundFeatureSchema,
|
||||||
@ -7,42 +7,67 @@ import {
|
|||||||
} from './playground-feature-schema';
|
} from './playground-feature-schema';
|
||||||
|
|
||||||
export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
|
export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
|
||||||
fc.boolean().chain((isEnabled) =>
|
fc
|
||||||
fc.record({
|
.tuple(
|
||||||
isEnabled: fc.constant(isEnabled),
|
fc.boolean(),
|
||||||
projectId: urlFriendlyString(),
|
variants(),
|
||||||
name: urlFriendlyString(),
|
fc.nat(),
|
||||||
variant: fc.record(
|
fc.record({
|
||||||
{
|
projectId: urlFriendlyString(),
|
||||||
name: urlFriendlyString(),
|
name: urlFriendlyString(),
|
||||||
enabled: fc.constant(isEnabled),
|
}),
|
||||||
payload: fc.oneof(
|
)
|
||||||
fc.record({
|
.map(([isEnabled, generatedVariants, activeVariantIndex, feature]) => {
|
||||||
type: fc.constant('json' as 'json'),
|
// the active variant is the disabled variant if the feature is
|
||||||
value: fc.json(),
|
// disabled or has no variants.
|
||||||
}),
|
let activeVariant = { name: 'disabled', enabled: false } as {
|
||||||
fc.record({
|
name: string;
|
||||||
type: fc.constant('csv' as 'csv'),
|
enabled: boolean;
|
||||||
value: fc
|
payload?: {
|
||||||
.array(fc.lorem())
|
type: 'string' | 'json' | 'csv';
|
||||||
.map((words) => words.join(',')),
|
value: string;
|
||||||
}),
|
};
|
||||||
fc.record({
|
};
|
||||||
type: fc.constant('string' as 'string'),
|
|
||||||
value: fc.string(),
|
if (generatedVariants.length && isEnabled) {
|
||||||
}),
|
const targetVariant =
|
||||||
),
|
generatedVariants[
|
||||||
},
|
activeVariantIndex % generatedVariants.length
|
||||||
{ requiredKeys: ['name', 'enabled'] },
|
];
|
||||||
),
|
const targetPayload = targetVariant.payload
|
||||||
}),
|
? (targetVariant.payload as {
|
||||||
);
|
type: 'string' | 'json' | 'csv';
|
||||||
|
value: string;
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
activeVariant = {
|
||||||
|
enabled: isEnabled,
|
||||||
|
name: targetVariant.name,
|
||||||
|
payload: targetPayload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
isEnabled,
|
||||||
|
variants: generatedVariants,
|
||||||
|
variant: activeVariant,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
test('playgroundFeatureSchema', () =>
|
test('playgroundFeatureSchema', () =>
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(
|
fc.property(
|
||||||
generate(),
|
generate(),
|
||||||
(data: PlaygroundFeatureSchema) =>
|
fc.context(),
|
||||||
validateSchema(playgroundFeatureSchema.$id, data) === undefined,
|
(data: PlaygroundFeatureSchema, ctx) => {
|
||||||
|
const results = validateSchema(
|
||||||
|
playgroundFeatureSchema.$id,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
ctx.log(JSON.stringify(results));
|
||||||
|
return results === undefined;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { variantSchema } from './variant-schema';
|
||||||
|
import { overrideSchema } from './override-schema';
|
||||||
|
|
||||||
export const playgroundFeatureSchema = {
|
export const playgroundFeatureSchema = {
|
||||||
$id: '#/components/schemas/playgroundFeatureSchema',
|
$id: '#/components/schemas/playgroundFeatureSchema',
|
||||||
@ -6,7 +8,7 @@ export const playgroundFeatureSchema = {
|
|||||||
'A simplified feature toggle model intended for the Unleash playground.',
|
'A simplified feature toggle model intended for the Unleash playground.',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['name', 'projectId', 'isEnabled', 'variant'],
|
required: ['name', 'projectId', 'isEnabled', 'variant', 'variants'],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', examples: ['my-feature'] },
|
name: { type: 'string', examples: ['my-feature'] },
|
||||||
projectId: { type: 'string', examples: ['my-project'] },
|
projectId: { type: 'string', examples: ['my-project'] },
|
||||||
@ -34,8 +36,9 @@ export const playgroundFeatureSchema = {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
examples: ['green'],
|
examples: ['green'],
|
||||||
},
|
},
|
||||||
|
variants: { type: 'array', items: { $ref: variantSchema.$id } },
|
||||||
},
|
},
|
||||||
components: { schemas: {} },
|
components: { schemas: { variantSchema, overrideSchema } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PlaygroundFeatureSchema = FromSchema<
|
export type PlaygroundFeatureSchema = FromSchema<
|
||||||
|
@ -5,12 +5,14 @@ import {
|
|||||||
} from '../../../lib/openapi/spec/playground-response-schema';
|
} from '../../../lib/openapi/spec/playground-response-schema';
|
||||||
import { validateSchema } from '../validate';
|
import { validateSchema } from '../validate';
|
||||||
import { generate as generateInput } from './playground-request-schema.test';
|
import { generate as generateInput } from './playground-request-schema.test';
|
||||||
import { generate as generateToggles } from './playground-feature-schema.test';
|
import { generate as generateFeature } from './playground-feature-schema.test';
|
||||||
|
|
||||||
const generate = (): Arbitrary<PlaygroundResponseSchema> =>
|
const generate = (): Arbitrary<PlaygroundResponseSchema> =>
|
||||||
fc.record({
|
fc.record({
|
||||||
input: generateInput(),
|
input: generateInput(),
|
||||||
features: fc.array(generateToggles()),
|
features: fc.uniqueArray(generateFeature(), {
|
||||||
|
selector: (feature) => feature.name,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
test('playgroundResponseSchema', () =>
|
test('playgroundResponseSchema', () =>
|
||||||
|
@ -2,6 +2,8 @@ import { FromSchema } from 'json-schema-to-ts';
|
|||||||
import { sdkContextSchema } from './sdk-context-schema';
|
import { sdkContextSchema } from './sdk-context-schema';
|
||||||
import { playgroundRequestSchema } from './playground-request-schema';
|
import { playgroundRequestSchema } from './playground-request-schema';
|
||||||
import { playgroundFeatureSchema } from './playground-feature-schema';
|
import { playgroundFeatureSchema } from './playground-feature-schema';
|
||||||
|
import { variantSchema } from './variant-schema';
|
||||||
|
import { overrideSchema } from './override-schema';
|
||||||
|
|
||||||
export const playgroundResponseSchema = {
|
export const playgroundResponseSchema = {
|
||||||
$id: '#/components/schemas/playgroundResponseSchema',
|
$id: '#/components/schemas/playgroundResponseSchema',
|
||||||
@ -15,16 +17,16 @@ export const playgroundResponseSchema = {
|
|||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: { $ref: playgroundFeatureSchema.$id },
|
||||||
$ref: playgroundFeatureSchema.$id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
sdkContextSchema,
|
|
||||||
playgroundRequestSchema,
|
|
||||||
playgroundFeatureSchema,
|
playgroundFeatureSchema,
|
||||||
|
playgroundRequestSchema,
|
||||||
|
sdkContextSchema,
|
||||||
|
variantSchema,
|
||||||
|
overrideSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -36,6 +36,11 @@ export class PlaygroundService {
|
|||||||
if (!head) {
|
if (!head) {
|
||||||
return [];
|
return [];
|
||||||
} else {
|
} else {
|
||||||
|
const variantsMap = toggles.reduce((acc, feature) => {
|
||||||
|
acc[feature.name] = feature.variants;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const client = await offlineUnleashClient(
|
const client = await offlineUnleashClient(
|
||||||
[head, ...rest],
|
[head, ...rest],
|
||||||
context,
|
context,
|
||||||
@ -60,6 +65,7 @@ export class PlaygroundService {
|
|||||||
),
|
),
|
||||||
variant: client.getVariant(feature.name, clientContext),
|
variant: client.getVariant(feature.name, clientContext),
|
||||||
name: feature.name,
|
name: feature.name,
|
||||||
|
variants: variantsMap[feature.name] || [],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -2,7 +2,7 @@ import fc, { Arbitrary } from 'fast-check';
|
|||||||
|
|
||||||
import { ALL_OPERATORS } from '../lib/util/constants';
|
import { ALL_OPERATORS } from '../lib/util/constants';
|
||||||
import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema';
|
import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema';
|
||||||
import { WeightType } from '../lib/types/model';
|
import { IVariant, WeightType } from '../lib/types/model';
|
||||||
import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema';
|
import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema';
|
||||||
import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema';
|
import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema';
|
||||||
|
|
||||||
@ -92,6 +92,49 @@ export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const variant = (): Arbitrary<IVariant> =>
|
||||||
|
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' 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(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ nil: undefined },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ requiredKeys: ['name', 'weight', 'weightType', 'stickiness'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const variants = (): Arbitrary<IVariant[]> =>
|
||||||
|
fc
|
||||||
|
.uniqueArray(variant(), {
|
||||||
|
maxLength: 1000,
|
||||||
|
selector: (variantInstance) => variantInstance.name,
|
||||||
|
})
|
||||||
|
.map((allVariants) =>
|
||||||
|
allVariants.map((variantInstance) => ({
|
||||||
|
...variantInstance,
|
||||||
|
weight: Math.round(1000 / allVariants.length),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
|
export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
|
||||||
fc.record(
|
fc.record(
|
||||||
{
|
{
|
||||||
@ -111,32 +154,7 @@ export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
|
|||||||
stale: fc.boolean(),
|
stale: fc.boolean(),
|
||||||
impressionData: fc.option(fc.boolean()),
|
impressionData: fc.option(fc.boolean()),
|
||||||
strategies: strategies(),
|
strategies: strategies(),
|
||||||
variants: fc.array(
|
variants: variants(),
|
||||||
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'] },
|
{ requiredKeys: ['name', 'enabled', 'project', 'strategies'] },
|
||||||
);
|
);
|
||||||
@ -157,3 +175,13 @@ test('url-friendly strings are URL-friendly', () =>
|
|||||||
/^[\w~.-]+$/.test(input),
|
/^[\w~.-]+$/.test(input),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
test('variant payloads are either present or undefined; never null', () =>
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
variant(),
|
||||||
|
(generatedVariant) =>
|
||||||
|
!!generatedVariant.payload ||
|
||||||
|
generatedVariant.payload === undefined,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
@ -1821,12 +1821,19 @@ Object {
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"variants": Object {
|
||||||
|
"items": Object {
|
||||||
|
"$ref": "#/components/schemas/variantSchema",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": Array [
|
"required": Array [
|
||||||
"name",
|
"name",
|
||||||
"projectId",
|
"projectId",
|
||||||
"isEnabled",
|
"isEnabled",
|
||||||
"variant",
|
"variant",
|
||||||
|
"variants",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
@ -7,9 +7,10 @@ import dbInit, { ITestDb } from '../helpers/database-init';
|
|||||||
import { IUnleashStores } from '../../../lib/types/stores';
|
import { IUnleashStores } from '../../../lib/types/stores';
|
||||||
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
|
import FeatureToggleService from '../../../lib/services/feature-toggle-service';
|
||||||
import { SegmentService } from '../../../lib/services/segment-service';
|
import { SegmentService } from '../../../lib/services/segment-service';
|
||||||
import { WeightType } from '../../../lib/types/model';
|
import { FeatureToggleDTO, IVariant } from '../../../lib/types/model';
|
||||||
import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema';
|
import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema';
|
||||||
import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client';
|
import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client';
|
||||||
|
import { ClientFeatureSchema } from '../../../lib/openapi/spec/client-feature-schema';
|
||||||
|
|
||||||
let stores: IUnleashStores;
|
let stores: IUnleashStores;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
@ -48,6 +49,15 @@ describe('the playground service (e2e)', () => {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}) => name === 'disabled' && !enabled;
|
}) => name === 'disabled' && !enabled;
|
||||||
|
|
||||||
|
const toFeatureToggleDTO = (
|
||||||
|
feature: ClientFeatureSchema,
|
||||||
|
): FeatureToggleDTO => ({
|
||||||
|
...feature,
|
||||||
|
// the arbitrary generator takes care of this
|
||||||
|
variants: feature.variants as IVariant[] | undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
test('should return the same enabled toggles as the raw SDK correctly mapped', async () => {
|
test('should return the same enabled toggles as the raw SDK correctly mapped', async () => {
|
||||||
await fc.assert(
|
await fc.assert(
|
||||||
fc
|
fc
|
||||||
@ -59,20 +69,7 @@ describe('the playground service (e2e)', () => {
|
|||||||
toggles.map((feature) =>
|
toggles.map((feature) =>
|
||||||
stores.featureToggleStore.create(
|
stores.featureToggleStore.create(
|
||||||
feature.project,
|
feature.project,
|
||||||
{
|
toFeatureToggleDTO(feature),
|
||||||
...feature,
|
|
||||||
createdAt: undefined,
|
|
||||||
variants: [
|
|
||||||
...(feature.variants ?? []).map(
|
|
||||||
(variant) => ({
|
|
||||||
...variant,
|
|
||||||
weightType:
|
|
||||||
WeightType.VARIABLE,
|
|
||||||
stickiness: 'default',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -154,4 +151,57 @@ describe('the playground service (e2e)', () => {
|
|||||||
testParams,
|
testParams,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('output toggles should have the same variants as input toggles', async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc
|
||||||
|
.asyncProperty(
|
||||||
|
clientFeatures({ minLength: 1 }),
|
||||||
|
generateContext(),
|
||||||
|
async (toggles, context) => {
|
||||||
|
await Promise.all(
|
||||||
|
toggles.map((feature) =>
|
||||||
|
stores.featureToggleStore.create(
|
||||||
|
feature.project,
|
||||||
|
toFeatureToggleDTO(feature),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projects = '*';
|
||||||
|
const env = 'default';
|
||||||
|
|
||||||
|
const serviceToggles: PlaygroundFeatureSchema[] =
|
||||||
|
await service.evaluateQuery(projects, env, context);
|
||||||
|
|
||||||
|
const variantsMap = toggles.reduce(
|
||||||
|
(acc, feature) => ({
|
||||||
|
...acc,
|
||||||
|
[feature.name]: feature.variants,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
serviceToggles.forEach((feature) => {
|
||||||
|
if (variantsMap[feature.name]) {
|
||||||
|
expect(feature.variants).toEqual(
|
||||||
|
expect.arrayContaining(
|
||||||
|
variantsMap[feature.name],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(variantsMap[feature.name]).toEqual(
|
||||||
|
expect.arrayContaining(feature.variants),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(feature.variants).toStrictEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.afterEach(async () => {
|
||||||
|
await stores.featureToggleStore.deleteAll();
|
||||||
|
}),
|
||||||
|
testParams,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user