diff --git a/src/lib/openapi/spec/playground-feature-schema.test.ts b/src/lib/openapi/spec/playground-feature-schema.test.ts index 1db02069cf..1e364ff788 100644 --- a/src/lib/openapi/spec/playground-feature-schema.test.ts +++ b/src/lib/openapi/spec/playground-feature-schema.test.ts @@ -1,5 +1,5 @@ import fc, { Arbitrary } from 'fast-check'; -import { urlFriendlyString } from '../../../test/arbitraries.test'; +import { urlFriendlyString, variants } from '../../../test/arbitraries.test'; import { validateSchema } from '../validate'; import { playgroundFeatureSchema, @@ -7,42 +7,67 @@ import { } from './playground-feature-schema'; export const generate = (): Arbitrary => - 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'] }, - ), - }), - ); + fc + .tuple( + fc.boolean(), + variants(), + fc.nat(), + fc.record({ + projectId: urlFriendlyString(), + name: urlFriendlyString(), + }), + ) + .map(([isEnabled, generatedVariants, activeVariantIndex, feature]) => { + // the active variant is the disabled variant if the feature is + // disabled or has no variants. + let activeVariant = { name: 'disabled', enabled: false } as { + name: string; + enabled: boolean; + payload?: { + type: 'string' | 'json' | 'csv'; + value: string; + }; + }; + + if (generatedVariants.length && isEnabled) { + const targetVariant = + generatedVariants[ + activeVariantIndex % generatedVariants.length + ]; + 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', () => fc.assert( fc.property( generate(), - (data: PlaygroundFeatureSchema) => - validateSchema(playgroundFeatureSchema.$id, data) === undefined, + fc.context(), + (data: PlaygroundFeatureSchema, ctx) => { + const results = validateSchema( + playgroundFeatureSchema.$id, + data, + ); + ctx.log(JSON.stringify(results)); + return results === undefined; + }, ), )); diff --git a/src/lib/openapi/spec/playground-feature-schema.ts b/src/lib/openapi/spec/playground-feature-schema.ts index 5eee0afc20..d65004ad2f 100644 --- a/src/lib/openapi/spec/playground-feature-schema.ts +++ b/src/lib/openapi/spec/playground-feature-schema.ts @@ -1,4 +1,6 @@ import { FromSchema } from 'json-schema-to-ts'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; export const playgroundFeatureSchema = { $id: '#/components/schemas/playgroundFeatureSchema', @@ -6,7 +8,7 @@ export const playgroundFeatureSchema = { 'A simplified feature toggle model intended for the Unleash playground.', type: 'object', additionalProperties: false, - required: ['name', 'projectId', 'isEnabled', 'variant'], + required: ['name', 'projectId', 'isEnabled', 'variant', 'variants'], properties: { name: { type: 'string', examples: ['my-feature'] }, projectId: { type: 'string', examples: ['my-project'] }, @@ -34,8 +36,9 @@ export const playgroundFeatureSchema = { nullable: true, examples: ['green'], }, + variants: { type: 'array', items: { $ref: variantSchema.$id } }, }, - components: { schemas: {} }, + components: { schemas: { variantSchema, overrideSchema } }, } as const; export type PlaygroundFeatureSchema = FromSchema< diff --git a/src/lib/openapi/spec/playground-response-schema.test.ts b/src/lib/openapi/spec/playground-response-schema.test.ts index e70f8ff168..09cca87e2a 100644 --- a/src/lib/openapi/spec/playground-response-schema.test.ts +++ b/src/lib/openapi/spec/playground-response-schema.test.ts @@ -5,12 +5,14 @@ import { } 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'; +import { generate as generateFeature } from './playground-feature-schema.test'; const generate = (): Arbitrary => fc.record({ input: generateInput(), - features: fc.array(generateToggles()), + features: fc.uniqueArray(generateFeature(), { + selector: (feature) => feature.name, + }), }); test('playgroundResponseSchema', () => diff --git a/src/lib/openapi/spec/playground-response-schema.ts b/src/lib/openapi/spec/playground-response-schema.ts index 8cb1dd54ad..8c676d0e21 100644 --- a/src/lib/openapi/spec/playground-response-schema.ts +++ b/src/lib/openapi/spec/playground-response-schema.ts @@ -2,6 +2,8 @@ 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'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; export const playgroundResponseSchema = { $id: '#/components/schemas/playgroundResponseSchema', @@ -15,16 +17,16 @@ export const playgroundResponseSchema = { }, features: { type: 'array', - items: { - $ref: playgroundFeatureSchema.$id, - }, + items: { $ref: playgroundFeatureSchema.$id }, }, }, components: { schemas: { - sdkContextSchema, - playgroundRequestSchema, playgroundFeatureSchema, + playgroundRequestSchema, + sdkContextSchema, + variantSchema, + overrideSchema, }, }, } as const; diff --git a/src/lib/services/playground-service.ts b/src/lib/services/playground-service.ts index 4936f7a8f9..0359794e0b 100644 --- a/src/lib/services/playground-service.ts +++ b/src/lib/services/playground-service.ts @@ -36,6 +36,11 @@ export class PlaygroundService { if (!head) { return []; } else { + const variantsMap = toggles.reduce((acc, feature) => { + acc[feature.name] = feature.variants; + return acc; + }, {}); + const client = await offlineUnleashClient( [head, ...rest], context, @@ -60,6 +65,7 @@ export class PlaygroundService { ), variant: client.getVariant(feature.name, clientContext), name: feature.name, + variants: variantsMap[feature.name] || [], }; }), ); diff --git a/src/test/arbitraries.test.ts b/src/test/arbitraries.test.ts index e6def58648..f248aa614a 100644 --- a/src/test/arbitraries.test.ts +++ b/src/test/arbitraries.test.ts @@ -2,7 +2,7 @@ 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 { IVariant, WeightType } from '../lib/types/model'; import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema'; import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema'; @@ -92,6 +92,49 @@ export const strategies = (): Arbitrary => ), ); +export const variant = (): Arbitrary => + 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 => + 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 => fc.record( { @@ -111,32 +154,7 @@ export const clientFeature = (name?: string): Arbitrary => 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(), - }), - ), - ), - }), - ), + variants: variants(), }, { requiredKeys: ['name', 'enabled', 'project', 'strategies'] }, ); @@ -157,3 +175,13 @@ test('url-friendly strings are URL-friendly', () => /^[\w~.-]+$/.test(input), ), )); + +test('variant payloads are either present or undefined; never null', () => + fc.assert( + fc.property( + variant(), + (generatedVariant) => + !!generatedVariant.payload || + generatedVariant.payload === undefined, + ), + )); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index b85be57f34..e91e0830b2 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1821,12 +1821,19 @@ Object { ], "type": "object", }, + "variants": Object { + "items": Object { + "$ref": "#/components/schemas/variantSchema", + }, + "type": "array", + }, }, "required": Array [ "name", "projectId", "isEnabled", "variant", + "variants", ], "type": "object", }, diff --git a/src/test/e2e/services/playground-service.test.ts b/src/test/e2e/services/playground-service.test.ts index 9dc54085c8..899c0ad74b 100644 --- a/src/test/e2e/services/playground-service.test.ts +++ b/src/test/e2e/services/playground-service.test.ts @@ -7,9 +7,10 @@ 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 { FeatureToggleDTO, IVariant } from '../../../lib/types/model'; import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema'; import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client'; +import { ClientFeatureSchema } from '../../../lib/openapi/spec/client-feature-schema'; let stores: IUnleashStores; let db: ITestDb; @@ -48,6 +49,15 @@ describe('the playground service (e2e)', () => { enabled: boolean; }) => 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 () => { await fc.assert( fc @@ -59,20 +69,7 @@ describe('the playground service (e2e)', () => { toggles.map((feature) => stores.featureToggleStore.create( feature.project, - { - ...feature, - createdAt: undefined, - variants: [ - ...(feature.variants ?? []).map( - (variant) => ({ - ...variant, - weightType: - WeightType.VARIABLE, - stickiness: 'default', - }), - ), - ], - }, + toFeatureToggleDTO(feature), ), ), ); @@ -154,4 +151,57 @@ describe('the playground service (e2e)', () => { 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, + ); + }); });