mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: project-specific segments tests and fixes (#3339)
## About the changes - Refactored some E2E tests to use our APIs - Added test cases for project-specific segments - Added validation to check a project can access a specific segment - Fixed an OpenAPI schema that was missing segments ## Discussion points https://github.com/Unleash/unleash/pull/3339/files#r1140008992
This commit is contained in:
		
							parent
							
								
									335374aa6d
								
							
						
					
					
						commit
						7a9ea22eed
					
				@ -100,23 +100,25 @@ const createVariants = async (feature: string, variants: IVariant[]) => {
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createProject = async () => {
 | 
			
		||||
const createProjects = async (projects: string[] = [DEFAULT_PROJECT]) => {
 | 
			
		||||
    await db.stores.environmentStore.create({
 | 
			
		||||
        name: DEFAULT_ENV,
 | 
			
		||||
        type: 'production',
 | 
			
		||||
    });
 | 
			
		||||
    await db.stores.projectStore.create({
 | 
			
		||||
        name: DEFAULT_PROJECT,
 | 
			
		||||
        description: '',
 | 
			
		||||
        id: DEFAULT_PROJECT,
 | 
			
		||||
        mode: 'open' as const,
 | 
			
		||||
    });
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`)
 | 
			
		||||
        .send({
 | 
			
		||||
            environment: DEFAULT_ENV,
 | 
			
		||||
        })
 | 
			
		||||
        .expect(200);
 | 
			
		||||
    for (const project of projects) {
 | 
			
		||||
        await db.stores.projectStore.create({
 | 
			
		||||
            name: project,
 | 
			
		||||
            description: '',
 | 
			
		||||
            id: project,
 | 
			
		||||
            mode: 'open' as const,
 | 
			
		||||
        });
 | 
			
		||||
        await app.request
 | 
			
		||||
            .post(`/api/admin/projects/${project}/environments`)
 | 
			
		||||
            .send({
 | 
			
		||||
                environment: DEFAULT_ENV,
 | 
			
		||||
            })
 | 
			
		||||
            .expect(200);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createSegment = (postData: UpsertSegmentSchema): Promise<ISegment> => {
 | 
			
		||||
@ -193,9 +195,74 @@ afterAll(async () => {
 | 
			
		||||
    await db.destroy();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('import-export for project-specific segments', () => {
 | 
			
		||||
    test('exports features with project-specific-segments', async () => {
 | 
			
		||||
        const segmentName = 'my-segment';
 | 
			
		||||
        const project = 'with-segments';
 | 
			
		||||
        await createProjects([project]);
 | 
			
		||||
        const segment = await createSegment({
 | 
			
		||||
            name: segmentName,
 | 
			
		||||
            project,
 | 
			
		||||
            constraints: [],
 | 
			
		||||
        });
 | 
			
		||||
        const strategy = {
 | 
			
		||||
            name: 'default',
 | 
			
		||||
            parameters: { rollout: '100', stickiness: 'default' },
 | 
			
		||||
            constraints: [
 | 
			
		||||
                {
 | 
			
		||||
                    contextName: 'appName',
 | 
			
		||||
                    values: ['test'],
 | 
			
		||||
                    operator: 'IN' as const,
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            segments: [segment.id],
 | 
			
		||||
        };
 | 
			
		||||
        await createToggle(
 | 
			
		||||
            {
 | 
			
		||||
                name: 'first_feature',
 | 
			
		||||
                description: 'the #1 feature',
 | 
			
		||||
            },
 | 
			
		||||
            strategy,
 | 
			
		||||
            [],
 | 
			
		||||
            project,
 | 
			
		||||
        );
 | 
			
		||||
        const { body } = await app.request
 | 
			
		||||
            .post('/api/admin/features-batch/export')
 | 
			
		||||
            .send({
 | 
			
		||||
                features: ['first_feature'],
 | 
			
		||||
                environment: 'default',
 | 
			
		||||
            })
 | 
			
		||||
            .set('Content-Type', 'application/json')
 | 
			
		||||
            .expect(200);
 | 
			
		||||
 | 
			
		||||
        const { name, ...resultStrategy } = strategy;
 | 
			
		||||
        expect(body).toMatchObject({
 | 
			
		||||
            features: [
 | 
			
		||||
                {
 | 
			
		||||
                    name: 'first_feature',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            featureStrategies: [resultStrategy],
 | 
			
		||||
            featureEnvironments: [
 | 
			
		||||
                {
 | 
			
		||||
                    enabled: false,
 | 
			
		||||
                    environment: 'default',
 | 
			
		||||
                    featureName: 'first_feature',
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
            segments: [
 | 
			
		||||
                {
 | 
			
		||||
                    id: segment.id,
 | 
			
		||||
                    name: segmentName,
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('exports features', async () => {
 | 
			
		||||
    const segmentName = 'my-segment';
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const segment = await createSegment({ name: segmentName, constraints: [] });
 | 
			
		||||
    const strategy = {
 | 
			
		||||
        name: 'default',
 | 
			
		||||
@ -249,6 +316,7 @@ test('exports features', async () => {
 | 
			
		||||
        ],
 | 
			
		||||
        segments: [
 | 
			
		||||
            {
 | 
			
		||||
                id: segment.id,
 | 
			
		||||
                name: segmentName,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
@ -256,7 +324,7 @@ test('exports features', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should export custom context fields from strategies and variants', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const strategyContext = {
 | 
			
		||||
        name: 'strategy-context',
 | 
			
		||||
        legalValues: [
 | 
			
		||||
@ -358,7 +426,7 @@ test('should export custom context fields from strategies and variants', async (
 | 
			
		||||
 | 
			
		||||
test('should export tags', async () => {
 | 
			
		||||
    const featureName = 'first_feature';
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    await createToggle(
 | 
			
		||||
        {
 | 
			
		||||
            name: featureName,
 | 
			
		||||
@ -397,7 +465,7 @@ test('should export tags', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('returns no features, when no feature was requested', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    await createToggle({
 | 
			
		||||
        name: 'first_feature',
 | 
			
		||||
        description: 'the #1 feature',
 | 
			
		||||
@ -551,7 +619,7 @@ const validateImport = (importPayload: ImportTogglesSchema, status = 200) =>
 | 
			
		||||
        .expect(status);
 | 
			
		||||
 | 
			
		||||
test('import features to existing project and environment', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
 | 
			
		||||
    await importToggles(defaultImportPayload);
 | 
			
		||||
 | 
			
		||||
@ -587,7 +655,7 @@ test('import features to existing project and environment', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('importing same JSON should work multiple times in a row', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    await importToggles(defaultImportPayload);
 | 
			
		||||
    await importToggles(defaultImportPayload);
 | 
			
		||||
 | 
			
		||||
@ -619,7 +687,7 @@ test('importing same JSON should work multiple times in a row', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('reject import with unknown context fields', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const contextField = {
 | 
			
		||||
        name: 'ContextField1',
 | 
			
		||||
        legalValues: [{ value: 'Value1', description: '' }],
 | 
			
		||||
@ -650,7 +718,7 @@ test('reject import with unknown context fields', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('reject import with unsupported strategies', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const importPayloadWithContextFields: ImportTogglesSchema = {
 | 
			
		||||
        ...defaultImportPayload,
 | 
			
		||||
        data: {
 | 
			
		||||
@ -673,7 +741,7 @@ test('reject import with unsupported strategies', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('validate import data', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const contextField: IContextFieldDto = {
 | 
			
		||||
        name: 'validate_context_field',
 | 
			
		||||
        legalValues: [{ value: 'Value1' }],
 | 
			
		||||
@ -731,7 +799,7 @@ test('validate import data', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should create new context', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    const context = {
 | 
			
		||||
        name: 'create-new-context',
 | 
			
		||||
        legalValues: [{ value: 'Value1' }],
 | 
			
		||||
@ -751,7 +819,7 @@ test('should create new context', async () => {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should not import archived features tags', async () => {
 | 
			
		||||
    await createProject();
 | 
			
		||||
    await createProjects();
 | 
			
		||||
    await importToggles(defaultImportPayload);
 | 
			
		||||
 | 
			
		||||
    await archiveFeature(defaultFeature);
 | 
			
		||||
 | 
			
		||||
@ -22,6 +22,13 @@ export const createFeatureStrategySchema = {
 | 
			
		||||
        parameters: {
 | 
			
		||||
            $ref: '#/components/schemas/parametersSchema',
 | 
			
		||||
        },
 | 
			
		||||
        segments: {
 | 
			
		||||
            type: 'array',
 | 
			
		||||
            description: 'Ids of segments to use for this strategy',
 | 
			
		||||
            items: {
 | 
			
		||||
                type: 'number',
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    components: {
 | 
			
		||||
        schemas: {
 | 
			
		||||
 | 
			
		||||
@ -80,10 +80,7 @@ const PATH_STRATEGY = `${PATH_STRATEGIES}/:strategyId`;
 | 
			
		||||
 | 
			
		||||
type ProjectFeaturesServices = Pick<
 | 
			
		||||
    IUnleashServices,
 | 
			
		||||
    | 'featureToggleServiceV2'
 | 
			
		||||
    | 'projectHealthService'
 | 
			
		||||
    | 'openApiService'
 | 
			
		||||
    | 'segmentService'
 | 
			
		||||
    'featureToggleServiceV2' | 'projectHealthService' | 'openApiService'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export default class ProjectFeaturesController extends Controller {
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,8 @@ export interface ISegmentService {
 | 
			
		||||
 | 
			
		||||
    getByStrategy(strategyId: string): Promise<ISegment[]>;
 | 
			
		||||
 | 
			
		||||
    get(id: number): Promise<ISegment>;
 | 
			
		||||
 | 
			
		||||
    getActive(): Promise<ISegment[]>;
 | 
			
		||||
 | 
			
		||||
    getAll(): Promise<ISegment[]>;
 | 
			
		||||
 | 
			
		||||
@ -194,7 +194,7 @@ class FeatureToggleService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async validateFeatureContext({
 | 
			
		||||
    async validateFeatureBelongsToProject({
 | 
			
		||||
        featureName,
 | 
			
		||||
        projectId,
 | 
			
		||||
    }: IFeatureContext): Promise<void> {
 | 
			
		||||
@ -207,9 +207,9 @@ class FeatureToggleService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    validateFeatureStrategyContext(
 | 
			
		||||
    validateUpdatedProperties(
 | 
			
		||||
        { featureName, projectId }: IFeatureContext,
 | 
			
		||||
        strategy: IFeatureStrategy,
 | 
			
		||||
        { featureName, projectId }: IFeatureStrategyContext,
 | 
			
		||||
    ): void {
 | 
			
		||||
        if (strategy.projectId !== projectId) {
 | 
			
		||||
            throw new InvalidOperationError(
 | 
			
		||||
@ -224,6 +224,27 @@ class FeatureToggleService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async validateProjectCanAccessSegments(
 | 
			
		||||
        projectId: string,
 | 
			
		||||
        segmentIds?: number[],
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        if (segmentIds && segmentIds.length > 0) {
 | 
			
		||||
            await Promise.all(
 | 
			
		||||
                segmentIds.map((segmentId) =>
 | 
			
		||||
                    this.segmentService.get(segmentId),
 | 
			
		||||
                ),
 | 
			
		||||
            ).then((segments) =>
 | 
			
		||||
                segments.map((segment) => {
 | 
			
		||||
                    if (segment.project && segment.project !== projectId) {
 | 
			
		||||
                        throw new BadDataError(
 | 
			
		||||
                            `The segment "${segment.name}" with id ${segment.id} does not belong to project "${projectId}".`,
 | 
			
		||||
                        );
 | 
			
		||||
                    }
 | 
			
		||||
                }),
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async validateConstraints(
 | 
			
		||||
        constraints: IConstraint[],
 | 
			
		||||
    ): Promise<IConstraint[]> {
 | 
			
		||||
@ -373,7 +394,12 @@ class FeatureToggleService {
 | 
			
		||||
        createdBy: string,
 | 
			
		||||
    ): Promise<Saved<IStrategyConfig>> {
 | 
			
		||||
        const { featureName, projectId, environment } = context;
 | 
			
		||||
        await this.validateFeatureContext(context);
 | 
			
		||||
        await this.validateFeatureBelongsToProject(context);
 | 
			
		||||
 | 
			
		||||
        await this.validateProjectCanAccessSegments(
 | 
			
		||||
            projectId,
 | 
			
		||||
            strategyConfig.segments,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
            strategyConfig.constraints &&
 | 
			
		||||
@ -468,7 +494,11 @@ class FeatureToggleService {
 | 
			
		||||
    ): Promise<Saved<IStrategyConfig>> {
 | 
			
		||||
        const { projectId, environment, featureName } = context;
 | 
			
		||||
        const existingStrategy = await this.featureStrategiesStore.get(id);
 | 
			
		||||
        this.validateFeatureStrategyContext(existingStrategy, context);
 | 
			
		||||
        this.validateUpdatedProperties(context, existingStrategy);
 | 
			
		||||
        await this.validateProjectCanAccessSegments(
 | 
			
		||||
            projectId,
 | 
			
		||||
            updates.segments,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (existingStrategy.id === id) {
 | 
			
		||||
            if (updates.constraints && updates.constraints.length > 0) {
 | 
			
		||||
@ -526,7 +556,7 @@ class FeatureToggleService {
 | 
			
		||||
        const { projectId, environment, featureName } = context;
 | 
			
		||||
 | 
			
		||||
        const existingStrategy = await this.featureStrategiesStore.get(id);
 | 
			
		||||
        this.validateFeatureStrategyContext(existingStrategy, context);
 | 
			
		||||
        this.validateUpdatedProperties(context, existingStrategy);
 | 
			
		||||
 | 
			
		||||
        if (existingStrategy.id === id) {
 | 
			
		||||
            existingStrategy.parameters[name] = String(value);
 | 
			
		||||
@ -589,7 +619,7 @@ class FeatureToggleService {
 | 
			
		||||
    ): Promise<void> {
 | 
			
		||||
        const existingStrategy = await this.featureStrategiesStore.get(id);
 | 
			
		||||
        const { featureName, projectId, environment } = context;
 | 
			
		||||
        this.validateFeatureStrategyContext(existingStrategy, context);
 | 
			
		||||
        this.validateUpdatedProperties(context, existingStrategy);
 | 
			
		||||
 | 
			
		||||
        await this.featureStrategiesStore.delete(id);
 | 
			
		||||
 | 
			
		||||
@ -667,7 +697,10 @@ class FeatureToggleService {
 | 
			
		||||
        userId,
 | 
			
		||||
    }: IGetFeatureParams): Promise<FeatureToggleWithEnvironment> {
 | 
			
		||||
        if (projectId) {
 | 
			
		||||
            await this.validateFeatureContext({ featureName, projectId });
 | 
			
		||||
            await this.validateFeatureBelongsToProject({
 | 
			
		||||
                featureName,
 | 
			
		||||
                projectId,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (environmentVariants) {
 | 
			
		||||
@ -901,7 +934,7 @@ class FeatureToggleService {
 | 
			
		||||
        userName: string,
 | 
			
		||||
        featureName: string,
 | 
			
		||||
    ): Promise<FeatureToggle> {
 | 
			
		||||
        await this.validateFeatureContext({ featureName, projectId });
 | 
			
		||||
        await this.validateFeatureBelongsToProject({ featureName, projectId });
 | 
			
		||||
 | 
			
		||||
        this.logger.info(`${userName} updates feature toggle ${featureName}`);
 | 
			
		||||
 | 
			
		||||
@ -1066,7 +1099,10 @@ class FeatureToggleService {
 | 
			
		||||
        const feature = await this.featureToggleStore.get(featureName);
 | 
			
		||||
 | 
			
		||||
        if (projectId) {
 | 
			
		||||
            await this.validateFeatureContext({ featureName, projectId });
 | 
			
		||||
            await this.validateFeatureBelongsToProject({
 | 
			
		||||
                featureName,
 | 
			
		||||
                projectId,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.featureToggleStore.archive(featureName);
 | 
			
		||||
 | 
			
		||||
@ -14,12 +14,18 @@ import {
 | 
			
		||||
} from '../../../../lib/util/segments';
 | 
			
		||||
import { collectIds } from '../../../../lib/util/collect-ids';
 | 
			
		||||
import { arraysHaveSameItems } from '../../../../lib/util/arraysHaveSameItems';
 | 
			
		||||
import { UpsertSegmentSchema } from 'lib/openapi';
 | 
			
		||||
import {
 | 
			
		||||
    CreateFeatureSchema,
 | 
			
		||||
    CreateFeatureStrategySchema,
 | 
			
		||||
    FeatureStrategySchema,
 | 
			
		||||
    UpsertSegmentSchema,
 | 
			
		||||
} from 'lib/openapi';
 | 
			
		||||
import { DEFAULT_ENV } from '../../../../lib/util';
 | 
			
		||||
import { DEFAULT_PROJECT } from '../../../../lib/types';
 | 
			
		||||
 | 
			
		||||
let db: ITestDb;
 | 
			
		||||
let app: IUnleashTest;
 | 
			
		||||
 | 
			
		||||
const FEATURES_ADMIN_BASE_PATH = '/api/admin/features';
 | 
			
		||||
const FEATURES_CLIENT_BASE_PATH = '/api/client/features';
 | 
			
		||||
 | 
			
		||||
const fetchSegments = (): Promise<ISegment[]> => {
 | 
			
		||||
@ -28,7 +34,7 @@ const fetchSegments = (): Promise<ISegment[]> => {
 | 
			
		||||
 | 
			
		||||
const fetchFeatures = (): Promise<IFeatureToggleClient[]> => {
 | 
			
		||||
    return app.request
 | 
			
		||||
        .get(FEATURES_ADMIN_BASE_PATH)
 | 
			
		||||
        .get(`/api/admin/features`)
 | 
			
		||||
        .expect(200)
 | 
			
		||||
        .then((res) => res.body.features);
 | 
			
		||||
};
 | 
			
		||||
@ -40,33 +46,86 @@ const fetchClientFeatures = (): Promise<IFeatureToggleClient[]> => {
 | 
			
		||||
        .then((res) => res.body.features);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createSegment = (postData: UpsertSegmentSchema): Promise<unknown> => {
 | 
			
		||||
const createSegment = (postData: UpsertSegmentSchema): Promise<ISegment> => {
 | 
			
		||||
    return app.services.segmentService.create(postData, {
 | 
			
		||||
        email: 'test@example.com',
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createFeatureToggle = (
 | 
			
		||||
    postData: object,
 | 
			
		||||
    expectStatusCode = 201,
 | 
			
		||||
): Promise<unknown> => {
 | 
			
		||||
    return app.request
 | 
			
		||||
        .post(FEATURES_ADMIN_BASE_PATH)
 | 
			
		||||
        .send(postData)
 | 
			
		||||
        .expect(expectStatusCode);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const addSegmentToStrategy = (
 | 
			
		||||
    segmentId: number,
 | 
			
		||||
    strategyId: string,
 | 
			
		||||
): Promise<unknown> => {
 | 
			
		||||
    return app.services.segmentService.addToStrategy(segmentId, strategyId);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const mockFeatureToggle = (): object => {
 | 
			
		||||
const mockStrategy = (segments: number[] = []) => {
 | 
			
		||||
    return {
 | 
			
		||||
        name: randomId(),
 | 
			
		||||
        parameters: {},
 | 
			
		||||
        constraints: [],
 | 
			
		||||
        segments,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createProjects = async (projects: string[] = [DEFAULT_PROJECT]) => {
 | 
			
		||||
    for (const project of projects) {
 | 
			
		||||
        await db.stores.projectStore.create({
 | 
			
		||||
            name: project,
 | 
			
		||||
            description: '',
 | 
			
		||||
            id: project,
 | 
			
		||||
            mode: 'open' as const,
 | 
			
		||||
        });
 | 
			
		||||
        await app.request
 | 
			
		||||
            .post(`/api/admin/projects/${project}/environments`)
 | 
			
		||||
            .send({
 | 
			
		||||
                environment: DEFAULT_ENV,
 | 
			
		||||
            })
 | 
			
		||||
            .expect(200);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createFeatureToggle = async (
 | 
			
		||||
    feature: CreateFeatureSchema,
 | 
			
		||||
    strategies: CreateFeatureStrategySchema[] = [mockStrategy()],
 | 
			
		||||
    project = DEFAULT_PROJECT,
 | 
			
		||||
    environment = DEFAULT_ENV,
 | 
			
		||||
    expectStatusCode = 201,
 | 
			
		||||
    expectSegmentStatusCodes: { status: number; message?: string }[] = [
 | 
			
		||||
        { status: 200 },
 | 
			
		||||
    ],
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
    await app.request
 | 
			
		||||
        .post(`/api/admin/projects/${project}/features`)
 | 
			
		||||
        .send(feature)
 | 
			
		||||
        .expect(expectStatusCode);
 | 
			
		||||
 | 
			
		||||
    let processed = 0;
 | 
			
		||||
    for (const strategy of strategies) {
 | 
			
		||||
        const { body, status } = await app.request
 | 
			
		||||
            .post(
 | 
			
		||||
                `/api/admin/projects/${project}/features/${feature.name}/environments/${environment}/strategies`,
 | 
			
		||||
            )
 | 
			
		||||
            .send(strategy);
 | 
			
		||||
        const expectation = expectSegmentStatusCodes[processed++];
 | 
			
		||||
        expect(status).toBe(expectation.status);
 | 
			
		||||
        if (expectation.message) {
 | 
			
		||||
            expect(JSON.stringify(body)).toContain(expectation.message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateFeatureStrategy = async (
 | 
			
		||||
    featureName: string,
 | 
			
		||||
    strategy: FeatureStrategySchema,
 | 
			
		||||
    project = DEFAULT_PROJECT,
 | 
			
		||||
    environment = DEFAULT_ENV,
 | 
			
		||||
    expectedStatus = 200,
 | 
			
		||||
): Promise<void> => {
 | 
			
		||||
    const { status } = await app.request
 | 
			
		||||
        .put(
 | 
			
		||||
            `/api/admin/projects/${project}/features/${featureName}/environments/${environment}/strategies/${strategy.id}`,
 | 
			
		||||
        )
 | 
			
		||||
        .send(strategy);
 | 
			
		||||
    expect(status).toBe(expectedStatus);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const mockFeatureToggle = () => {
 | 
			
		||||
    return {
 | 
			
		||||
        name: randomId(),
 | 
			
		||||
        strategies: [{ name: randomId(), constraints: [], parameters: {} }],
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -99,19 +158,19 @@ const fetchClientResponse = (): Promise<{
 | 
			
		||||
const createTestSegments = async () => {
 | 
			
		||||
    const constraints = mockConstraints();
 | 
			
		||||
 | 
			
		||||
    await createSegment({ name: 'S1', constraints });
 | 
			
		||||
    await createSegment({ name: 'S2', constraints });
 | 
			
		||||
    await createSegment({ name: 'S3', constraints });
 | 
			
		||||
    const segment1 = await createSegment({ name: 'S1', constraints });
 | 
			
		||||
    const segment2 = await createSegment({ name: 'S2', constraints });
 | 
			
		||||
    const segment3 = await createSegment({ name: 'S3', constraints });
 | 
			
		||||
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle(), [
 | 
			
		||||
        mockStrategy([segment1.id, segment2.id]),
 | 
			
		||||
    ]);
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle(), [
 | 
			
		||||
        mockStrategy([segment2.id]),
 | 
			
		||||
    ]);
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
 | 
			
		||||
    const [feature1, feature2] = await fetchFeatures();
 | 
			
		||||
    const [segment1, segment2] = await fetchSegments();
 | 
			
		||||
    await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segment2.id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segment2.id, feature2.strategies[0].id);
 | 
			
		||||
    return [segment1, segment2, segment3];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
beforeAll(async () => {
 | 
			
		||||
@ -168,53 +227,50 @@ test('should validate segment constraint values limit with multiple constraints'
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should validate feature strategy segment limit', async () => {
 | 
			
		||||
    await createSegment({ name: 'S1', constraints: [] });
 | 
			
		||||
    await createSegment({ name: 'S2', constraints: [] });
 | 
			
		||||
    await createSegment({ name: 'S3', constraints: [] });
 | 
			
		||||
    await createSegment({ name: 'S4', constraints: [] });
 | 
			
		||||
    await createSegment({ name: 'S5', constraints: [] });
 | 
			
		||||
    await createSegment({ name: 'S6', constraints: [] });
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
    const [feature1] = await fetchFeatures();
 | 
			
		||||
    const segments = await fetchSegments();
 | 
			
		||||
    const segments: ISegment[] = [];
 | 
			
		||||
    for (const id of [1, 2, 3, 4, 5, 6]) {
 | 
			
		||||
        segments.push(await createSegment({ name: `S${id}`, constraints: [] }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await addSegmentToStrategy(segments[0].id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segments[1].id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segments[2].id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segments[3].id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segments[4].id, feature1.strategies[0].id);
 | 
			
		||||
 | 
			
		||||
    await expect(
 | 
			
		||||
        addSegmentToStrategy(segments[5].id, feature1.strategies[0].id),
 | 
			
		||||
    ).rejects.toThrow(
 | 
			
		||||
        `Strategies may not have more than ${DEFAULT_STRATEGY_SEGMENTS_LIMIT} segments`,
 | 
			
		||||
    await createFeatureToggle(
 | 
			
		||||
        mockFeatureToggle(),
 | 
			
		||||
        [mockStrategy(segments.map((s) => s.id))],
 | 
			
		||||
        DEFAULT_PROJECT,
 | 
			
		||||
        DEFAULT_ENV,
 | 
			
		||||
        201,
 | 
			
		||||
        [
 | 
			
		||||
            {
 | 
			
		||||
                status: 400,
 | 
			
		||||
                message: `Strategies may not have more than ${DEFAULT_STRATEGY_SEGMENTS_LIMIT} segments`,
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
    );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test('should clone feature strategy segments', async () => {
 | 
			
		||||
    const constraints = mockConstraints();
 | 
			
		||||
    await createSegment({ name: 'S1', constraints });
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
    const segment1 = await createSegment({ name: 'S1', constraints });
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle(), [
 | 
			
		||||
        mockStrategy([segment1.id]),
 | 
			
		||||
    ]);
 | 
			
		||||
    await createFeatureToggle(mockFeatureToggle());
 | 
			
		||||
 | 
			
		||||
    const [feature1, feature2] = await fetchFeatures();
 | 
			
		||||
    const strategy1 = feature1.strategies[0].id;
 | 
			
		||||
    const strategy2 = feature2.strategies[0].id;
 | 
			
		||||
    const [segment1] = await fetchSegments();
 | 
			
		||||
    await addSegmentToStrategy(segment1.id, feature1.strategies[0].id);
 | 
			
		||||
 | 
			
		||||
    let segments1 = await app.services.segmentService.getByStrategy(strategy1);
 | 
			
		||||
    let segments2 = await app.services.segmentService.getByStrategy(strategy2);
 | 
			
		||||
    let segments1 = await app.services.segmentService.getByStrategy(strategy1!);
 | 
			
		||||
    let segments2 = await app.services.segmentService.getByStrategy(strategy2!);
 | 
			
		||||
    expect(collectIds(segments1)).toEqual([segment1.id]);
 | 
			
		||||
    expect(collectIds(segments2)).toEqual([]);
 | 
			
		||||
 | 
			
		||||
    await app.services.segmentService.cloneStrategySegments(
 | 
			
		||||
        strategy1,
 | 
			
		||||
        strategy2,
 | 
			
		||||
        strategy1!,
 | 
			
		||||
        strategy2!,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    segments1 = await app.services.segmentService.getByStrategy(strategy1);
 | 
			
		||||
    segments2 = await app.services.segmentService.getByStrategy(strategy2);
 | 
			
		||||
    segments1 = await app.services.segmentService.getByStrategy(strategy1!);
 | 
			
		||||
    segments2 = await app.services.segmentService.getByStrategy(strategy2!);
 | 
			
		||||
    expect(collectIds(segments1)).toEqual([segment1.id]);
 | 
			
		||||
    expect(collectIds(segments2)).toEqual([segment1.id]);
 | 
			
		||||
});
 | 
			
		||||
@ -239,9 +295,18 @@ test('should inline segment constraints into features by default', async () => {
 | 
			
		||||
 | 
			
		||||
    const [feature1, feature2, feature3] = await fetchFeatures();
 | 
			
		||||
    const [, , segment3] = await fetchSegments();
 | 
			
		||||
    await addSegmentToStrategy(segment3.id, feature1.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segment3.id, feature2.strategies[0].id);
 | 
			
		||||
    await addSegmentToStrategy(segment3.id, feature3.strategies[0].id);
 | 
			
		||||
 | 
			
		||||
    // add segment3 to all features
 | 
			
		||||
    for (const feature of [feature1, feature2, feature3]) {
 | 
			
		||||
        const strategy = {
 | 
			
		||||
            ...feature.strategies[0],
 | 
			
		||||
            segments: feature.strategies[0].segments ?? [],
 | 
			
		||||
        };
 | 
			
		||||
        await updateFeatureStrategy(feature.name, {
 | 
			
		||||
            ...strategy,
 | 
			
		||||
            segments: [...strategy.segments, segment3.id],
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const clientFeatures = await fetchClientFeatures();
 | 
			
		||||
    const clientStrategies = clientFeatures.flatMap((f) => f.strategies);
 | 
			
		||||
@ -316,3 +381,59 @@ test('should send all segments that are in use by feature', async () => {
 | 
			
		||||
        true,
 | 
			
		||||
    );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('project-specific segments', () => {
 | 
			
		||||
    test(`can create a toggle with a project-specific segment`, async () => {
 | 
			
		||||
        const segmentName = 'my-segment';
 | 
			
		||||
        const project = randomId();
 | 
			
		||||
        await createProjects([project]);
 | 
			
		||||
        const segment = await createSegment({
 | 
			
		||||
            name: segmentName,
 | 
			
		||||
            project,
 | 
			
		||||
            constraints: [],
 | 
			
		||||
        });
 | 
			
		||||
        const strategy = {
 | 
			
		||||
            name: 'default',
 | 
			
		||||
            parameters: {},
 | 
			
		||||
            constraints: [],
 | 
			
		||||
            segments: [segment.id],
 | 
			
		||||
        };
 | 
			
		||||
        await createFeatureToggle(
 | 
			
		||||
            {
 | 
			
		||||
                name: 'first_feature',
 | 
			
		||||
                description: 'the #1 feature',
 | 
			
		||||
            },
 | 
			
		||||
            [strategy],
 | 
			
		||||
            project,
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    test(`can't create a toggle with a segment from a different project`, async () => {
 | 
			
		||||
        const segmentName = 'my-segment';
 | 
			
		||||
        const project1 = randomId();
 | 
			
		||||
        const project2 = randomId();
 | 
			
		||||
        await createProjects([project1, project2]);
 | 
			
		||||
        const segment = await createSegment({
 | 
			
		||||
            name: segmentName,
 | 
			
		||||
            project: project1,
 | 
			
		||||
            constraints: [],
 | 
			
		||||
        });
 | 
			
		||||
        const strategy = {
 | 
			
		||||
            name: 'default',
 | 
			
		||||
            parameters: {},
 | 
			
		||||
            constraints: [],
 | 
			
		||||
            segments: [segment.id],
 | 
			
		||||
        };
 | 
			
		||||
        await createFeatureToggle(
 | 
			
		||||
            {
 | 
			
		||||
                name: 'first_feature',
 | 
			
		||||
                description: 'the #1 feature',
 | 
			
		||||
            },
 | 
			
		||||
            [strategy],
 | 
			
		||||
            project2,
 | 
			
		||||
            DEFAULT_ENV,
 | 
			
		||||
            201,
 | 
			
		||||
            [{ status: 400 }],
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -889,6 +889,13 @@ exports[`should serve the OpenAPI spec 1`] = `
 | 
			
		||||
          "parameters": {
 | 
			
		||||
            "$ref": "#/components/schemas/parametersSchema",
 | 
			
		||||
          },
 | 
			
		||||
          "segments": {
 | 
			
		||||
            "description": "Ids of segments to use for this strategy",
 | 
			
		||||
            "items": {
 | 
			
		||||
              "type": "number",
 | 
			
		||||
            },
 | 
			
		||||
            "type": "array",
 | 
			
		||||
          },
 | 
			
		||||
          "sortOrder": {
 | 
			
		||||
            "type": "number",
 | 
			
		||||
          },
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user