1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: open-source segments 🚀 (#4690)

We love all open-source Unleash users. in 2022 we built the [segment
capability](https://docs.getunleash.io/reference/segments) (v4.13) as an
enterprise feature, simplify life for our customers.

Now it is time to contribute it to the world 🌏

---------

Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
Ivar Conradi Østhus 2023-09-19 13:24:26 +02:00 committed by GitHub
parent bff1bd1026
commit 013efac46b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1607 additions and 88 deletions

View File

@ -3,5 +3,4 @@ export const C = 'C';
export const E = 'E';
export const EEA = 'EEA';
export const RE = 'RE';
export const SE = 'SE';
export const UG = 'UG';

View File

@ -141,8 +141,7 @@ export const FeatureStrategyEdit = () => {
savedStrategySegments && setSegments(savedStrategySegments);
}, [JSON.stringify(savedStrategySegments)]);
const segmentsToSubmit = uiConfig?.flags.SE ? segments : [];
const payload = createStrategyPayload(strategy, segmentsToSubmit);
const payload = createStrategyPayload(strategy, segments);
const onStrategyEdit = async (payload: IFeatureStrategyPayload) => {
await updateStrategyOnFeature(

View File

@ -230,15 +230,10 @@ export const FeatureStrategyForm = ({
}));
}}
/>
<ConditionallyRender
condition={Boolean(uiConfig.flags.SE)}
show={
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
}
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
<FeatureStrategyConstraints
projectId={feature.project}

View File

@ -243,11 +243,9 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
}
const listItems = [
Boolean(uiConfig.flags.SE) &&
strategySegments &&
strategySegments.length > 0 && (
<FeatureOverviewSegment segments={strategySegments} />
),
strategySegments && strategySegments.length > 0 && (
<FeatureOverviewSegment segments={strategySegments} />
),
constraints.length > 0 && (
<ConstraintAccordionList
constraints={constraints}

View File

@ -355,7 +355,6 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"flag": "SE",
"hidden": false,
"layout": "main",
"menu": {},
@ -365,7 +364,6 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"flag": "SE",
"hidden": false,
"layout": "main",
"menu": {},
@ -375,7 +373,6 @@ exports[`returns all baseRoutes 1`] = `
},
{
"component": [Function],
"flag": "SE",
"hidden": false,
"menu": {
"advanced": true,

View File

@ -4,7 +4,7 @@ import { StrategiesList } from 'component/strategies/StrategiesList/StrategiesLi
import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList';
import { IntegrationList } from 'component/integrations/IntegrationList/IntegrationList';
import Login from 'component/user/Login/Login';
import { EEA, P, SE } from 'component/common/flags';
import { EEA, P } from 'component/common/flags';
import { NewUser } from 'component/user/NewUser/NewUser';
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
@ -374,7 +374,6 @@ export const routes: IRoute[] = [
type: 'protected',
layout: 'main',
menu: {},
flag: SE,
},
{
path: '/segments/edit/:segmentId',
@ -384,7 +383,6 @@ export const routes: IRoute[] = [
type: 'protected',
layout: 'main',
menu: {},
flag: SE,
},
{
path: '/segments',
@ -393,7 +391,6 @@ export const routes: IRoute[] = [
hidden: false,
type: 'protected',
menu: { mobile: true, advanced: true },
flag: SE,
},
// History

View File

@ -3,7 +3,6 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
import { styled } from '@mui/material';
import { PlaygroundRequestSchema, PlaygroundStrategySchema } from 'openapi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConstraintExecution } from './ConstraintExecution/ConstraintExecution';
import { SegmentExecution } from './SegmentExecution/SegmentExecution';
import { PlaygroundResultStrategyExecutionParameters } from './StrategyExecutionParameters/StrategyExecutionParameters';
@ -28,10 +27,7 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
}) => {
const { name, constraints, segments, parameters } = strategyResult;
const { uiConfig } = useUiConfig();
const hasSegments =
Boolean(uiConfig.flags.SE) && Boolean(segments && segments.length > 0);
const hasSegments = Boolean(segments && segments.length > 0);
const hasConstraints = Boolean(constraints && constraints?.length > 0);
const hasExecutionParameters =
name !== 'default' &&

View File

@ -86,11 +86,7 @@ const EditDefaultStrategy = () => {
}
}, [JSON.stringify(allSegments), JSON.stringify(strategy?.segments)]);
const segmentsToSubmit = uiConfig?.flags.SE ? segments : [];
const payload = createStrategyPayload(
defaultStrategy as any,
segmentsToSubmit
);
const payload = createStrategyPayload(defaultStrategy as any, segments);
const onDefaultStrategyEdit = async (
payload: CreateFeatureStrategySchema

View File

@ -155,15 +155,10 @@ export const ProjectDefaultStrategyForm = ({
}));
}}
/>
<ConditionallyRender
condition={Boolean(uiConfig.flags.SE)}
show={
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
}
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
<FeatureStrategyConstraints
projectId={projectId}

View File

@ -37,6 +37,7 @@ export const SegmentProjectAlert = ({
},
});
};
const projectList = (
<StyledUl>
{Array.from(projectsUsed).map(projectId => (
@ -87,17 +88,6 @@ export const SegmentProjectAlert = ({
);
}
if (availableProjects.length === 1) {
return (
<StyledAlert severity="info">
You can't specify a project other than{' '}
<strong>{availableProjects[0].name}</strong> for this segment
because it is used here:
{projectList}
</StyledAlert>
);
}
return null;
};

View File

@ -1,9 +1,9 @@
import { useCallback } from 'react';
import useSWR, { mutate } from 'swr';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { ISegment } from 'interfaces/segment';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
export interface IUseSegmentsOutput {
segments?: ISegment[];
@ -19,15 +19,9 @@ export const useSegments = (strategyId?: string): IUseSegmentsOutput => {
? formatApiPath(`api/admin/segments/strategies/${strategyId}`)
: formatApiPath('api/admin/segments');
const { data, error, mutate } = useConditionalSWR(
Boolean(uiConfig.flags?.SE),
[],
url,
() => fetchSegments(url),
{
refreshInterval: 15 * 1000,
}
);
const { data, error, mutate } = useSWR(url, () => fetchSegments(url), {
refreshInterval: 15 * 1000,
});
const refetchSegments = useCallback(() => {
mutate().catch(console.warn);

View File

@ -40,7 +40,6 @@ export type UiFlags = {
P: boolean;
RE: boolean;
EEA?: boolean;
SE?: boolean;
T?: boolean;
UNLEASH_CLOUD?: boolean;
UG?: boolean;

View File

@ -0,0 +1,468 @@
import { Request, Response } from 'express';
import Controller from '../../routes/controller';
import {
IAuthRequest,
IUnleashConfig,
IUnleashServices,
Logger,
} from '../../server-impl';
import {
AdminSegmentSchema,
UpdateFeatureStrategySegmentsSchema,
UpsertSegmentSchema,
adminSegmentSchema,
createRequestSchema,
createResponseSchema,
resourceCreatedResponseSchema,
updateFeatureStrategySchema,
} from '../../openapi';
import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import { ISegmentService } from '../../segments/segment-service-interface';
import { SegmentStrategiesSchema } from '../../openapi/spec/admin-strategies-schema';
import { AccessService, OpenApiService } from '../../services';
import {
CREATE_SEGMENT,
DELETE_SEGMENT,
IFlagResolver,
NONE,
UPDATE_FEATURE_STRATEGY,
UPDATE_SEGMENT,
serializeDates,
} from '../../types';
import {
segmentsSchema,
SegmentsSchema,
} from '../../openapi/spec/segments-schema';
import { anonymiseKeys } from '../../util';
import { BadDataError } from '../../error';
type IUpdateFeatureStrategySegmentsRequest = IAuthRequest<
{},
undefined,
UpdateFeatureStrategySegmentsSchema
>;
export class SegmentsController extends Controller {
private logger: Logger;
private segmentService: ISegmentService;
private accessService: AccessService;
private flagResolver: IFlagResolver;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
segmentService,
accessService,
openApiService,
}: Pick<
IUnleashServices,
'segmentService' | 'accessService' | 'openApiService'
>,
) {
super(config);
this.flagResolver = config.flagResolver;
this.config = config;
this.logger = config.getLogger('/admin-api/segments.ts');
this.segmentService = segmentService;
this.accessService = accessService;
this.openApiService = openApiService;
this.route({
method: 'post',
path: '/validate',
handler: this.validate,
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Validates if a segment name exists',
description:
'Uses the name provided in the body of the request to validate if the given name exists or not',
tags: ['Segments'],
operationId: 'validateSegment',
requestBody: createRequestSchema('nameSchema'),
responses: {
204: emptyResponse,
...getStandardResponses(400, 401, 409, 415),
},
}),
],
});
this.route({
method: 'get',
path: '/strategies/:strategyId',
handler: this.getSegmentsByStrategy,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Segments'],
operationId: 'getSegmentsByStrategyId',
summary: 'Get strategy segments',
description:
"Retrieve all segments that are referenced by the specified strategy. Returns an empty list of segments if the strategy ID doesn't exist.",
responses: {
200: createResponseSchema('segmentsSchema'),
},
}),
],
});
this.route({
method: 'post',
path: '/strategies',
handler: this.updateFeatureStrategySegments,
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Update strategy segments',
description:
'Sets the segments of the strategy specified to be exactly the ones passed in the payload. Any segments that were used by the strategy before will be removed if they are not in the provided list of segments.',
tags: ['Strategies'],
operationId: 'updateFeatureStrategySegments',
requestBody: createRequestSchema(
'updateFeatureStrategySegmentsSchema',
),
responses: {
201: resourceCreatedResponseSchema(
'updateFeatureStrategySegmentsSchema',
),
...getStandardResponses(400, 401, 403, 415),
},
}),
],
});
this.route({
method: 'get',
path: '/:id/strategies',
handler: this.getStrategiesBySegment,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Segments'],
operationId: 'getStrategiesBySegmentId',
summary: 'Get strategies that reference segment',
description:
'Retrieve all strategies that reference the specified segment.',
responses: {
200: createResponseSchema('adminStrategiesSchema'),
},
}),
],
});
this.route({
method: 'delete',
path: '/:id',
handler: this.removeSegment,
permission: DELETE_SEGMENT,
acceptAnyContentType: true,
middleware: [
openApiService.validPath({
summary: 'Deletes a segment by id',
description:
'Deletes a segment by its id, if not found returns a 409 error',
tags: ['Segments'],
operationId: 'removeSegment',
responses: {
204: emptyResponse,
...getStandardResponses(401, 403, 409),
},
}),
],
});
this.route({
method: 'put',
path: '/:id',
handler: this.updateSegment,
permission: UPDATE_SEGMENT,
middleware: [
openApiService.validPath({
summary: 'Update segment by id',
description:
'Updates the content of the segment with the provided payload. Any fields not specified will be left untouched.',
tags: ['Segments'],
operationId: 'updateSegment',
responses: {
204: emptyResponse,
...getStandardResponses(400, 401, 403, 409, 415),
},
}),
],
});
this.route({
method: 'get',
path: '/:id',
handler: this.getSegment,
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Get a segment',
description: 'Retrieves a segment based on its ID.',
tags: ['Segments'],
operationId: 'getSegment',
responses: {
200: createResponseSchema('adminSegmentSchema'),
...getStandardResponses(404),
},
}),
],
});
this.route({
method: 'post',
path: '',
handler: this.createSegment,
permission: CREATE_SEGMENT,
middleware: [
openApiService.validPath({
summary: 'Create a new segment',
description:
'Creates a new segment using the payload provided',
tags: ['Segments'],
operationId: 'createSegment',
requestBody: createRequestSchema('upsertSegmentSchema'),
responses: {
201: resourceCreatedResponseSchema(
'adminSegmentSchema',
),
...getStandardResponses(400, 401, 403, 409, 415),
},
}),
],
});
this.route({
method: 'get',
path: '',
handler: this.getSegments,
permission: NONE,
middleware: [
openApiService.validPath({
summary: 'Get all segments',
description:
'Retrieves all segments that exist in this Unleash instance.',
tags: ['Segments'],
operationId: 'getSegments',
responses: {
200: createResponseSchema('segmentsSchema'),
},
}),
],
});
}
async validate(
req: Request<unknown, unknown, { name: string }>,
res: Response,
): Promise<void> {
const { name } = req.body;
await this.segmentService.validateName(name);
res.status(204).send();
}
async getSegmentsByStrategy(
req: Request<{ strategyId: string }>,
res: Response<SegmentsSchema>,
): Promise<void> {
const { strategyId } = req.params;
const segments = await this.segmentService.getByStrategy(strategyId);
const responseBody = this.flagResolver.isEnabled('anonymiseEventLog')
? {
segments: anonymiseKeys(segments, ['createdBy']),
}
: { segments };
this.openApiService.respondWithValidation(
200,
res,
segmentsSchema.$id,
serializeDates(responseBody),
);
}
async updateFeatureStrategySegments(
req: IUpdateFeatureStrategySegmentsRequest,
res: Response<UpdateFeatureStrategySegmentsSchema>,
): Promise<void> {
const { projectId, environmentId, strategyId, segmentIds } = req.body;
const hasFeatureStrategyPermission = this.accessService.hasPermission(
req.user,
UPDATE_FEATURE_STRATEGY,
projectId,
environmentId,
);
if (!hasFeatureStrategyPermission) {
res.status(403).send();
return;
}
if (segmentIds.length > this.config.strategySegmentsLimit) {
throw new BadDataError(
`Strategies may not have more than ${this.config.strategySegmentsLimit} segments`,
);
}
const segments = await this.segmentService.getByStrategy(strategyId);
const currentSegmentIds = segments.map((segment) => segment.id);
await this.removeFromStrategy(
strategyId,
currentSegmentIds.filter((id) => !segmentIds.includes(id)),
);
await this.addToStrategy(
strategyId,
segmentIds.filter((id) => !currentSegmentIds.includes(id)),
);
this.openApiService.respondWithValidation(
201,
res,
updateFeatureStrategySchema.$id,
req.body,
{
location: `strategies/${strategyId}`,
},
);
}
async getStrategiesBySegment(
req: IAuthRequest<{ id: number }>,
res: Response<SegmentStrategiesSchema>,
): Promise<void> {
const { id } = req.params;
const strategies = await this.segmentService.getStrategies(id);
// Remove unnecessary IFeatureStrategy fields from the response.
const segmentStrategies = strategies.map((strategy) => ({
id: strategy.id,
projectId: strategy.projectId,
featureName: strategy.featureName,
strategyName: strategy.strategyName,
environment: strategy.environment,
}));
res.json({
strategies: segmentStrategies,
});
}
async removeSegment(
req: IAuthRequest<{ id: string }>,
res: Response,
): Promise<void> {
const id = Number(req.params.id);
const strategies = await this.segmentService.getStrategies(id);
if (strategies.length > 0) {
res.status(409).send();
} else {
await this.segmentService.delete(id, req.user);
res.status(204).send();
}
}
async updateSegment(
req: IAuthRequest<{ id: string }>,
res: Response,
): Promise<void> {
const id = Number(req.params.id);
const updateRequest: UpsertSegmentSchema = {
name: req.body.name,
description: req.body.description,
project: req.body.project,
constraints: req.body.constraints,
};
await this.segmentService.update(id, updateRequest, req.user);
res.status(204).send();
}
async getSegment(
req: Request<{ id: string }>,
res: Response,
): Promise<void> {
const id = Number(req.params.id);
const segment = await this.segmentService.get(id);
if (this.flagResolver.isEnabled('anonymiseEventLog')) {
res.json(anonymiseKeys(segment, ['createdBy']));
} else {
res.json(segment);
}
}
async createSegment(
req: IAuthRequest<any, any, UpsertSegmentSchema>,
res: Response<AdminSegmentSchema>,
): Promise<void> {
const createRequest = req.body;
const segment = await this.segmentService.create(
createRequest,
req.user,
);
this.openApiService.respondWithValidation(
201,
res,
adminSegmentSchema.$id,
serializeDates(segment),
{ location: `segments/${segment.id}` },
);
}
async getSegments(
req: IAuthRequest,
res: Response<SegmentsSchema>,
): Promise<void> {
const segments = await this.segmentService.getAll();
const response = {
segments: this.flagResolver.isEnabled('anonymiseEventLog')
? anonymiseKeys(segments, ['createdBy'])
: segments,
};
this.openApiService.respondWithValidation<SegmentsSchema>(
200,
res,
segmentsSchema.$id,
serializeDates(response),
);
}
private async removeFromStrategy(
strategyId: string,
segmentIds: number[],
): Promise<void> {
await Promise.all(
segmentIds.map((id) =>
this.segmentService.removeFromStrategy(id, strategyId),
),
);
}
private async addToStrategy(
strategyId: string,
segmentIds: number[],
): Promise<void> {
await Promise.all(
segmentIds.map((id) =>
this.segmentService.addToStrategy(id, strategyId),
),
);
}
}

View File

@ -6,6 +6,7 @@ import {
addonsSchema,
addonTypeSchema,
adminCountSchema,
adminSegmentSchema,
adminFeaturesQuerySchema,
advancedPlaygroundRequestSchema,
advancedPlaygroundResponseSchema,
@ -158,6 +159,8 @@ import {
createGroupSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
segmentsSchema,
updateFeatureStrategySegmentsSchema,
dependentFeatureSchema,
createDependentFeatureSchema,
} from './spec';
@ -177,6 +180,7 @@ import { createApplicationSchema } from './spec/create-application-schema';
import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema';
import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema';
import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema';
import { segmentStrategiesSchema } from './spec/admin-strategies-schema';
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
@ -210,6 +214,8 @@ interface OpenAPIV3DocumentWithServers extends OpenAPIV3.Document {
export const schemas: UnleashSchemas = {
adminCountSchema,
adminFeaturesQuerySchema,
adminSegmentSchema,
adminStrategiesSchema: segmentStrategiesSchema,
addonParameterSchema,
addonSchema,
addonCreateUpdateSchema,
@ -377,6 +383,8 @@ export const schemas: UnleashSchemas = {
createFeatureNamingPatternSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
segmentsSchema,
updateFeatureStrategySegmentsSchema,
dependentFeatureSchema,
createDependentFeatureSchema,
};

View File

@ -0,0 +1,54 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateEnvironmentSchema 1`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "additionalProperties",
"message": "must NOT have additional properties",
"params": {
"additionalProperty": "additional",
},
"schemaPath": "#/additionalProperties",
},
],
"schema": "#/components/schemas/adminSegmentSchema",
}
`;
exports[`updateEnvironmentSchema 2`] = `undefined`;
exports[`updateEnvironmentSchema 3`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'id'",
"params": {
"missingProperty": "id",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/adminSegmentSchema",
}
`;
exports[`updateEnvironmentSchema 4`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "type",
"message": "must be object",
"params": {
"type": "object",
},
"schemaPath": "#/type",
},
],
"schema": "#/components/schemas/adminSegmentSchema",
}
`;

View File

@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`segmentStrategiesSchema 1`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "type",
"message": "must be object",
"params": {
"type": "object",
},
"schemaPath": "#/type",
},
],
"schema": "#/components/schemas/segmentStrategiesSchema",
}
`;
exports[`segmentStrategiesSchema 2`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'strategies'",
"params": {
"missingProperty": "strategies",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/segmentStrategiesSchema",
}
`;
exports[`segmentStrategiesSchema 3`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'strategies'",
"params": {
"missingProperty": "strategies",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/segmentStrategiesSchema",
}
`;
exports[`segmentStrategiesSchema 4`] = `
{
"errors": [
{
"instancePath": "/strategies/0",
"keyword": "required",
"message": "must have required property 'id'",
"params": {
"missingProperty": "id",
},
"schemaPath": "#/properties/strategies/items/required",
},
],
"schema": "#/components/schemas/segmentStrategiesSchema",
}
`;

View File

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateEnvironmentSchema 1`] = `undefined`;
exports[`updateEnvironmentSchema 2`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "type",
"message": "must be object",
"params": {
"type": "object",
},
"schemaPath": "#/type",
},
],
"schema": "#/components/schemas/segmentsSchema",
}
`;

View File

@ -0,0 +1,35 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`updateFeatureStrategySegmentsSchema schema 1`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'projectId'",
"params": {
"missingProperty": "projectId",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/updateFeatureStrategySegmentsSchema",
}
`;
exports[`updateFeatureStrategySegmentsSchema schema 2`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'projectId'",
"params": {
"missingProperty": "projectId",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/updateFeatureStrategySegmentsSchema",
}
`;

View File

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`upsertSegmentSchema 1`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'constraints'",
"params": {
"missingProperty": "constraints",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/upsertSegmentSchema",
}
`;
exports[`upsertSegmentSchema 2`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'name'",
"params": {
"missingProperty": "name",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/upsertSegmentSchema",
}
`;
exports[`upsertSegmentSchema 3`] = `
{
"errors": [
{
"instancePath": "",
"keyword": "required",
"message": "must have required property 'name'",
"params": {
"missingProperty": "name",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/upsertSegmentSchema",
}
`;

View File

@ -0,0 +1,59 @@
import { validateSchema } from '../validate';
import { AdminSegmentSchema } from './admin-segment-schema';
test('updateEnvironmentSchema', () => {
const data: AdminSegmentSchema = {
id: 1,
name: 'release',
constraints: [],
createdAt: '2022-07-25 06:00:00',
createdBy: 'test',
description: 'a description',
};
expect(
validateSchema('#/components/schemas/adminSegmentSchema', data),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/adminSegmentSchema', {
id: 1,
name: 'release',
constraints: [],
createdAt: '2022-07-25 06:00:00',
}),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/adminSegmentSchema', {
id: 1,
name: 'release',
constraints: [],
createdAt: '2022-07-25 06:00:00',
additional: 'property',
}),
).toMatchSnapshot();
expect(
validateSchema('#/components/schemas/adminSegmentSchema', {
id: 1,
name: 'release',
constraints: [],
createdAt: 'wrong-format',
}),
).toMatchSnapshot();
expect(
validateSchema('#/components/schemas/adminSegmentSchema', {
name: 'release',
constraints: [],
}),
).toMatchSnapshot();
expect(
validateSchema(
'#/components/schemas/adminSegmentSchema',
'not an object',
),
).toMatchSnapshot();
});

View File

@ -0,0 +1,78 @@
import { FromSchema } from 'json-schema-to-ts';
import { constraintSchema } from './constraint-schema';
export const adminSegmentSchema = {
$id: '#/components/schemas/adminSegmentSchema',
type: 'object',
required: ['id', 'name', 'constraints', 'createdAt'],
description:
'A description of a [segment](https://docs.getunleash.io/reference/segments)',
additionalProperties: false,
properties: {
id: {
type: 'integer',
description: 'The ID of this segment',
example: 2,
minimum: 0,
},
name: {
type: 'string',
description: 'The name of this segment',
example: 'ios-users',
},
description: {
type: 'string',
nullable: true,
description: 'The description for this segment',
example: 'IOS users segment',
},
constraints: {
type: 'array',
description:
'The list of constraints that are used in this segment',
items: {
$ref: '#/components/schemas/constraintSchema',
},
},
usedInFeatures: {
type: 'integer',
minimum: 0,
description: 'The number of projects that use this segment',
example: 3,
nullable: true,
},
usedInProjects: {
type: 'integer',
minimum: 0,
description: 'The number of projects that use this segment',
example: 2,
nullable: true,
},
project: {
type: 'string',
nullable: true,
example: 'red-vista',
description:
'The project the segment belongs to. Only present if the segment is a project-specific segment.',
},
createdBy: {
description: "The creator's email or username",
example: 'someone@example.com',
type: 'string',
nullable: true,
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'When the segment was created',
example: '2023-04-12T11:13:31.960Z',
},
},
components: {
schemas: {
constraintSchema,
},
},
} as const;
export type AdminSegmentSchema = FromSchema<typeof adminSegmentSchema>;

View File

@ -0,0 +1,43 @@
import { validateSchema } from '../validate';
test('segmentStrategiesSchema', () => {
const validExamples = [
{ strategies: [] },
{
strategies: [
{
id: 'test',
projectId: '2',
featureName: 'featureName',
strategyName: 'strategyName',
environment: 'environment',
},
],
},
];
validExamples.forEach((obj) => {
expect(
validateSchema('#/components/schemas/segmentStrategiesSchema', obj),
).toBeUndefined();
});
const invalidExamples = [
'not an object',
{},
{ notStrategies: [] },
{
strategies: [
{
featureName: 'featureName',
strategyName: 'strategyName',
environment: 'environment',
},
],
},
];
invalidExamples.forEach((obj) => {
expect(
validateSchema('#/components/schemas/segmentStrategiesSchema', obj),
).toMatchSnapshot();
});
});

View File

@ -0,0 +1,58 @@
import { FromSchema } from 'json-schema-to-ts';
export const segmentStrategiesSchema = {
$id: '#/components/schemas/segmentStrategiesSchema',
type: 'object',
required: ['strategies'],
description: 'A collection of strategies belonging to a specified segment.',
properties: {
strategies: {
description: 'The list of strategies',
type: 'array',
items: {
type: 'object',
required: [
'id',
'featureName',
'projectId',
'environment',
'strategyName',
],
properties: {
id: {
type: 'string',
description: 'The ID of the strategy',
example: 'e465c813-cffb-4232-b184-82b1d6fe9d3d',
},
featureName: {
type: 'string',
description: 'The ID of the strategy',
example: 'new-signup-flow',
},
projectId: {
type: 'string',
description:
'The ID of the project that the strategy belongs to.',
example: 'red-vista',
},
environment: {
type: 'string',
description:
'The ID of the environment that the strategy belongs to.',
example: 'development',
},
strategyName: {
type: 'string',
description: "The name of the strategy's type.",
example: 'flexibleRollout',
},
},
},
},
},
components: {},
} as const;
export type SegmentStrategiesSchema = FromSchema<
typeof segmentStrategiesSchema
>;

View File

@ -1,7 +1,7 @@
import { FromSchema } from 'json-schema-to-ts';
export const contextFieldStrategiesSchema = {
$id: '#/components/schemas/segmentStrategiesSchema',
$id: '#/components/schemas/contextFieldStrategiesSchema',
type: 'object',
description:
'A wrapper object containing all strategies that use a specific context field',

View File

@ -157,5 +157,8 @@ export * from './create-group-schema';
export * from './application-usage-schema';
export * from './dora-features-schema';
export * from './project-dora-metrics-schema';
export * from './admin-segment-schema';
export * from './segments-schema';
export * from './update-feature-strategy-segments-schema';
export * from './dependent-feature-schema';
export * from './create-dependent-feature-schema';

View File

@ -0,0 +1,27 @@
import { validateSchema } from '../validate';
import { SegmentsSchema } from './segments-schema';
test('updateEnvironmentSchema', () => {
const data: SegmentsSchema = {
segments: [],
};
expect(
validateSchema('#/components/schemas/segmentsSchema', data),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/segmentsSchema', {
segments: [],
additional: 'property',
}),
).toBeUndefined();
expect(
validateSchema('#/components/schemas/segmentsSchema', {}),
).toMatchSnapshot();
expect(
validateSchema('#/components/schemas/segmentsSchema', 'not an object'),
).toMatchSnapshot();
});

View File

@ -0,0 +1,27 @@
import { FromSchema } from 'json-schema-to-ts';
import { adminSegmentSchema } from './admin-segment-schema';
import { constraintSchema } from './constraint-schema';
export const segmentsSchema = {
$id: '#/components/schemas/segmentsSchema',
description:
'Data containing a list of [segments](https://docs.getunleash.io/reference/segments)',
type: 'object',
properties: {
segments: {
type: 'array',
description: 'A list of segments',
items: {
$ref: '#/components/schemas/adminSegmentSchema',
},
},
},
components: {
schemas: {
adminSegmentSchema,
constraintSchema,
},
},
} as const;
export type SegmentsSchema = FromSchema<typeof segmentsSchema>;

View File

@ -0,0 +1,36 @@
import { validateSchema } from '../validate';
import { UpdateFeatureStrategySegmentsSchema } from './update-feature-strategy-segments-schema';
test('updateFeatureStrategySegmentsSchema schema', () => {
const data: UpdateFeatureStrategySegmentsSchema = {
strategyId: '1',
segmentIds: [1, 2],
projectId: 'default',
environmentId: 'default',
additional: 'property',
};
expect(
validateSchema(
'#/components/schemas/updateFeatureStrategySegmentsSchema',
data,
),
).toBeUndefined();
expect(
validateSchema(
'#/components/schemas/updateFeatureStrategySegmentsSchema',
{},
),
).toMatchSnapshot();
expect(
validateSchema(
'#/components/schemas/updateFeatureStrategySegmentsSchema',
{
strategyId: '1',
segmentIds: [],
},
),
).toMatchSnapshot();
});

View File

@ -0,0 +1,40 @@
import { FromSchema } from 'json-schema-to-ts';
export const updateFeatureStrategySegmentsSchema = {
$id: '#/components/schemas/updateFeatureStrategySegmentsSchema',
type: 'object',
required: ['projectId', 'strategyId', 'environmentId', 'segmentIds'],
description: 'Data required to update segments for a strategy.',
properties: {
projectId: {
type: 'string',
description: 'The ID of the project that the strategy belongs to.',
example: 'red-vista',
},
strategyId: {
type: 'string',
description: 'The ID of the strategy to update segments for.',
example: '15d1e20b-6310-4e17-a0c2-9fb84de3053a',
},
environmentId: {
type: 'string',
description: 'The ID of the strategy environment.',
example: 'development',
},
segmentIds: {
type: 'array',
description:
'The new list of segments (IDs) to use for this strategy. Any segments not in this list will be removed from the strategy.',
example: [1, 5, 6],
items: {
type: 'integer',
minimum: 0,
},
},
},
components: {},
} as const;
export type UpdateFeatureStrategySegmentsSchema = FromSchema<
typeof updateFeatureStrategySegmentsSchema
>;

View File

@ -0,0 +1,44 @@
import { validateSchema } from '../validate';
test('upsertSegmentSchema', () => {
const validObjects = [
{
name: 'segment',
constraints: [],
},
{
name: 'segment',
description: 'description',
constraints: [],
},
{
name: 'segment',
description: 'description',
constraints: [],
additional: 'property',
},
];
validObjects.forEach((obj) =>
expect(
validateSchema('#/components/schemas/upsertSegmentSchema', obj),
).toBeUndefined(),
);
const invalidObjects = [
{
name: 'segment',
},
{
description: 'description',
constraints: [],
},
{},
];
invalidObjects.forEach((obj) =>
expect(
validateSchema('#/components/schemas/upsertSegmentSchema', obj),
).toMatchSnapshot(),
);
});

View File

@ -4,46 +4,34 @@ import { constraintSchema } from './constraint-schema';
export const upsertSegmentSchema = {
$id: '#/components/schemas/upsertSegmentSchema',
type: 'object',
description:
'Represents a segment of users defined by a set of constraints.',
description: 'Data used to create or update a segment',
required: ['name', 'constraints'],
properties: {
name: {
description: 'The name of the segment',
example: 'beta-users',
type: 'string',
description: 'The name of the segment.',
},
description: {
type: 'string',
nullable: true,
description: 'The description of the segment.',
description: 'A description of what the segment is for',
example: 'Users willing to help us test and build new features.',
},
project: {
type: 'string',
nullable: true,
description:
'Project from where this segment will be accessible. If none is defined the segment will be global (i.e. accessible from any project).',
description: 'The project the segment belongs to if any.',
example: 'red-vista',
},
constraints: {
type: 'array',
description:
'List of constraints that determine which users will be part of the segment',
description: 'The list of constraints that make up this segment',
items: {
$ref: '#/components/schemas/constraintSchema',
},
},
},
example: {
name: 'segment name',
description: 'segment description',
project: 'optional project id',
constraints: [
{
contextName: 'environment',
operator: 'IN',
values: ['production', 'staging'],
},
],
},
components: {
schemas: {
constraintSchema,

View File

@ -32,6 +32,7 @@ import MaintenanceController from './maintenance';
import { createKnexTransactionStarter } from '../../db/transaction';
import { Db } from '../../db/db';
import ExportImportController from '../../features/export-import-toggles/export-import-controller';
import { SegmentsController } from '../../features/segment/segment-controller';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
@ -133,7 +134,10 @@ class AdminApi extends Controller {
`/projects`,
new FavoritesController(config, services).router,
);
this.app.use(
`/segments`,
new SegmentsController(config, services).router,
);
this.app.use(
'/maintenance',
new MaintenanceController(config, services).router,

View File

@ -0,0 +1,424 @@
import { randomId } from '../../../../lib/util/random-id';
import {
IFeatureStrategy,
IFeatureToggleClient,
ISegment,
} from '../../../../lib/types/model';
import { collectIds } from '../../../../lib/util/collect-ids';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import {
addStrategyToFeatureEnv,
createFeatureToggle,
} from '../../helpers/app.utils';
import {
IUnleashTest,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
let app: IUnleashTest;
let db: ITestDb;
const SEGMENTS_BASE_PATH = '/api/admin/segments';
const FEATURES_LIST_BASE_PATH = '/api/admin/features';
// Recursively change all Date properties to string properties.
type SerializeDatesDeep<T> = {
[P in keyof T]: T[P] extends Date ? string : SerializeDatesDeep<T[P]>;
};
const fetchSegments = (): Promise<SerializeDatesDeep<ISegment[]>> =>
app.request
.get(SEGMENTS_BASE_PATH)
.expect(200)
.then((res) => res.body.segments);
const fetchSegmentsByStrategy = (
strategyId: string,
): Promise<SerializeDatesDeep<ISegment[]>> =>
app.request
.get(`${SEGMENTS_BASE_PATH}/strategies/${strategyId}`)
.expect(200)
.then((res) => res.body.segments);
const fetchFeatures = (): Promise<IFeatureToggleClient[]> =>
app.request
.get(FEATURES_LIST_BASE_PATH)
.expect(200)
.then((res) => res.body.features);
const fetchSegmentStrategies = (
segmentId: number,
): Promise<IFeatureStrategy[]> =>
app.request
.get(`${SEGMENTS_BASE_PATH}/${segmentId}/strategies`)
.expect(200)
.then((res) => res.body.strategies);
const createSegment = (
postData: object,
expectStatusCode = 201,
): Promise<unknown> =>
app.request
.post(SEGMENTS_BASE_PATH)
.send(postData)
.expect(expectStatusCode);
const updateSegment = (
id: number,
postData: object,
expectStatusCode = 204,
): Promise<unknown> =>
app.request
.put(`${SEGMENTS_BASE_PATH}/${id}`)
.send(postData)
.expect(expectStatusCode);
const addSegmentsToStrategy = (
segmentIds: number[],
strategyId: string,
expectStatusCode = 201,
): Promise<unknown> =>
app.request
.post(`${SEGMENTS_BASE_PATH}/strategies`)
.set('Content-type', 'application/json')
.send({
strategyId,
segmentIds,
projectId: 'default',
environmentId: 'default',
additional: 'property',
})
.expect(expectStatusCode);
const mockFeatureToggle = () => ({
name: randomId(),
strategies: [{ name: 'flexibleRollout', constraints: [], parameters: {} }],
});
const validateSegment = (
postData: object,
expectStatusCode = 204,
): Promise<unknown> =>
app.request
.post(`${SEGMENTS_BASE_PATH}/validate`)
.set('Content-type', 'application/json')
.send(postData)
.expect(expectStatusCode);
beforeAll(async () => {
db = await dbInit('segments_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
anonymiseEventLog: true,
},
},
});
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
afterEach(async () => {
await db.stores.segmentStore.deleteAll();
await db.stores.featureToggleStore.deleteAll();
});
test('should validate segments', async () => {
await createSegment({ something: 'a' }, 400);
await createSegment({ name: randomId(), something: 'b' }, 400);
await createSegment({ name: randomId(), constraints: 'b' }, 400);
await createSegment({ constraints: [] }, 400);
await createSegment({ name: randomId(), constraints: [{}] }, 400);
await createSegment({ name: randomId(), constraints: [] });
await createSegment({ name: randomId(), description: '', constraints: [] });
});
test('should fail on missing properties', async () => {
const res = await app.request
.post(`${SEGMENTS_BASE_PATH}/strategies`)
.set('Content-type', 'application/json')
.send({
projectId: 'default',
environmentId: 'default',
additional: 'property',
});
expect(res.status).toBe(400);
});
test('should create segments', async () => {
await createSegment({ name: 'a', constraints: [] });
await createSegment({ name: 'c', constraints: [] });
await createSegment({ name: 'b', constraints: [] });
const segments = await fetchSegments();
expect(segments.map((s) => s.name)).toEqual(['a', 'b', 'c']);
});
test('should update segments', async () => {
await createSegment({ name: 'a', constraints: [] });
const [segmentA] = await fetchSegments();
expect(segmentA.id).toBeGreaterThan(0);
expect(segmentA.name).toEqual('a');
expect(segmentA.createdAt).toBeDefined();
expect(segmentA.constraints.length).toEqual(0);
await updateSegment(segmentA.id, { ...segmentA, name: 'b' });
const [segmentB] = await fetchSegments();
expect(segmentB.id).toEqual(segmentA.id);
expect(segmentB.name).toEqual('b');
expect(segmentB.createdAt).toBeDefined();
expect(segmentB.constraints.length).toEqual(0);
});
test('should update segment constraints', async () => {
const constraintA = { contextName: 'a', operator: 'IN', values: ['x'] };
const constraintB = { contextName: 'b', operator: 'IN', values: ['y'] };
await createSegment({ name: 'a', constraints: [constraintA] });
const [segmentA] = await fetchSegments();
expect(segmentA.constraints).toEqual([constraintA]);
await app.request
.put(`${SEGMENTS_BASE_PATH}/${segmentA.id}`)
.send({ ...segmentA, constraints: [constraintB, constraintA] })
.expect(204);
const [segmentB] = await fetchSegments();
expect(segmentB.constraints).toEqual([constraintB, constraintA]);
});
test('should delete segments', async () => {
await createSegment({ name: 'a', constraints: [] });
const segments = await fetchSegments();
expect(segments.length).toEqual(1);
await app.request
.delete(`${SEGMENTS_BASE_PATH}/${segments[0].id}`)
.expect(204);
expect((await fetchSegments()).length).toEqual(0);
});
test('should not delete segments used by strategies', async () => {
await createSegment({ name: 'a', constraints: [] });
const toggle = mockFeatureToggle();
await createFeatureToggle(app, toggle);
const [segment] = await fetchSegments();
await addStrategyToFeatureEnv(
app,
{ ...toggle.strategies[0] },
'default',
toggle.name,
);
const [feature] = await fetchFeatures();
//@ts-ignore
await addSegmentsToStrategy([segment.id], feature.strategies[0].id);
const segments = await fetchSegments();
expect(segments.length).toEqual(1);
await app.request
.delete(`${SEGMENTS_BASE_PATH}/${segments[0].id}`)
.expect(409);
expect((await fetchSegments()).length).toEqual(1);
});
test('should list strategies by segment', async () => {
await createSegment({ name: 'S1', constraints: [] });
await createSegment({ name: 'S2', constraints: [] });
await createSegment({ name: 'S3', constraints: [] });
const toggle1 = mockFeatureToggle();
const toggle2 = mockFeatureToggle();
const toggle3 = mockFeatureToggle();
await createFeatureToggle(app, toggle1);
await createFeatureToggle(app, toggle2);
await createFeatureToggle(app, toggle3);
await addStrategyToFeatureEnv(
app,
{ ...toggle1.strategies[0] },
'default',
toggle1.name,
);
await addStrategyToFeatureEnv(
app,
{ ...toggle1.strategies[0] },
'default',
toggle2.name,
);
await addStrategyToFeatureEnv(
app,
{ ...toggle3.strategies[0] },
'default',
toggle3.name,
);
const [feature1, feature2, feature3] = await fetchFeatures();
const [segment1, segment2, segment3] = await fetchSegments();
await addSegmentsToStrategy(
[segment1.id, segment2.id, segment3.id],
//@ts-ignore
feature1.strategies[0].id,
);
await addSegmentsToStrategy(
[segment2.id, segment3.id],
//@ts-ignore
feature2.strategies[0].id,
);
//@ts-ignore
await addSegmentsToStrategy([segment3.id], feature3.strategies[0].id);
const segmentStrategies1 = await fetchSegmentStrategies(segment1.id);
const segmentStrategies2 = await fetchSegmentStrategies(segment2.id);
const segmentStrategies3 = await fetchSegmentStrategies(segment3.id);
expect(collectIds(segmentStrategies1)).toEqual(
collectIds(feature1.strategies),
);
expect(collectIds(segmentStrategies2)).toEqual(
collectIds([...feature1.strategies, ...feature2.strategies]),
);
expect(collectIds(segmentStrategies3)).toEqual(
collectIds([
...feature1.strategies,
...feature2.strategies,
...feature3.strategies,
]),
);
});
test('should list segments by strategy', async () => {
await createSegment({ name: 'S1', constraints: [] });
await createSegment({ name: 'S2', constraints: [] });
await createSegment({ name: 'S3', constraints: [] });
const toggle1 = mockFeatureToggle();
const toggle2 = mockFeatureToggle();
const toggle3 = mockFeatureToggle();
await createFeatureToggle(app, toggle1);
await createFeatureToggle(app, toggle2);
await createFeatureToggle(app, toggle3);
await addStrategyToFeatureEnv(
app,
{ ...toggle1.strategies[0] },
'default',
toggle1.name,
);
await addStrategyToFeatureEnv(
app,
{ ...toggle1.strategies[0] },
'default',
toggle2.name,
);
await addStrategyToFeatureEnv(
app,
{ ...toggle3.strategies[0] },
'default',
toggle3.name,
);
const [feature1, feature2, feature3] = await fetchFeatures();
const [segment1, segment2, segment3] = await fetchSegments();
await addSegmentsToStrategy(
[segment1.id, segment2.id, segment3.id],
//@ts-ignore
feature1.strategies[0].id,
);
await addSegmentsToStrategy(
[segment2.id, segment3.id],
//@ts-ignore
feature2.strategies[0].id,
);
//@ts-ignore
await addSegmentsToStrategy([segment3.id], feature3.strategies[0].id);
const strategySegments1 = await fetchSegmentsByStrategy(
//@ts-ignore
feature1.strategies[0].id,
);
const strategySegments2 = await fetchSegmentsByStrategy(
//@ts-ignore
feature2.strategies[0].id,
);
const strategySegments3 = await fetchSegmentsByStrategy(
//@ts-ignore
feature3.strategies[0].id,
);
expect(collectIds(strategySegments1)).toEqual(
collectIds([segment1, segment2, segment3]),
);
expect(collectIds(strategySegments2)).toEqual(
collectIds([segment2, segment3]),
);
expect(collectIds(strategySegments3)).toEqual(collectIds([segment3]));
});
test('should reject duplicate segment names', async () => {
await validateSegment({ name: 'a' });
await createSegment({ name: 'a', constraints: [] });
await validateSegment({ name: 'a' }, 409);
await validateSegment({ name: 'b' });
});
test('should reject empty segment names', async () => {
await validateSegment({ name: 'a' });
await validateSegment({}, 400);
await validateSegment({ name: '' }, 400);
});
test('should reject duplicate segment names on create', async () => {
await createSegment({ name: 'a', constraints: [] });
await createSegment({ name: 'a', constraints: [] }, 409);
await validateSegment({ name: 'b' });
});
test('should reject duplicate segment names on update', async () => {
await createSegment({ name: 'a', constraints: [] });
await createSegment({ name: 'b', constraints: [] });
const [segmentA, segmentB] = await fetchSegments();
await updateSegment(segmentA.id, { name: 'b', constraints: [] }, 409);
await updateSegment(segmentB.id, { name: 'a', constraints: [] }, 409);
await updateSegment(segmentA.id, { name: 'a', constraints: [] });
await updateSegment(segmentA.id, { name: 'c', constraints: [] });
});
test('Should anonymise createdBy field if anonymiseEventLog flag is set', async () => {
await createSegment({ name: 'a', constraints: [] });
await createSegment({ name: 'b', constraints: [] });
const segments = await fetchSegments();
expect(segments).toHaveLength(2);
expect(segments[0].createdBy).toContain('unleash.run');
});
test('Should show usage in features and projects', async () => {
await createSegment({ name: 'a', constraints: [] });
const toggle = mockFeatureToggle();
await createFeatureToggle(app, toggle);
const [segment] = await fetchSegments();
await addStrategyToFeatureEnv(
app,
{ ...toggle.strategies[0] },
'default',
toggle.name,
);
const [feature] = await fetchFeatures();
//@ts-ignore
await addSegmentsToStrategy([segment.id], feature.strategies[0].id);
const segments = await fetchSegments();
expect(segments).toMatchObject([{ usedInFeatures: 1, usedInProjects: 1 }]);
});

View File

@ -0,0 +1,26 @@
import { CreateFeatureStrategySchema } from 'lib/openapi';
import { IUnleashTest } from './test-helper';
export const FEATURES_BASE_PATH = '/api/admin/projects/default/features';
export const ADMIN_BASE_PATH = '/api/admin';
export const createFeatureToggle = (
app: IUnleashTest,
postData: object,
expectStatusCode = 201,
): Promise<unknown> =>
app.request
.post(FEATURES_BASE_PATH)
.send(postData)
.expect(expectStatusCode);
export const addStrategyToFeatureEnv = (
app: IUnleashTest,
postData: CreateFeatureStrategySchema,
envName: string,
featureName: string,
expectStatusCode = 200,
): Promise<any> => {
const url = `${ADMIN_BASE_PATH}/projects/default/features/${featureName}/environments/${envName}/strategies`;
return app.request.post(url).send(postData).expect(expectStatusCode);
};

View File

@ -6,7 +6,8 @@ import VideoContent from '@site/src/components/VideoContent.jsx'
:::info Availability
Segments are available to Unleash Pro and Unleash Enterprise users since **Unleash 4.13**.
Segments are available to Unleash Pro and Unleash Enterprise users since **Unleash 4.13** and
was made available for Open Source from Unleash v5.5.
:::