1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

feat: search now also returns segments used (#5429)

This commit is contained in:
Jaanus Sellin 2023-11-28 21:34:57 +02:00 committed by GitHub
parent 91d616cb6a
commit 51f87bdfd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 331 additions and 58 deletions

View File

@ -6,6 +6,7 @@ import {
import getLogger from '../../../test/fixtures/no-logger'; import getLogger from '../../../test/fixtures/no-logger';
import { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters'; import { FeatureSearchQueryParameters } from '../../openapi/spec/feature-search-query-parameters';
import { IUnleashStores } from '../../types'; import { IUnleashStores } from '../../types';
import { DEFAULT_ENV } from '../../util';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
@ -470,7 +471,7 @@ test('should not return duplicate entries when sorting by last seen', async () =
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'production', name: 'production',
type: 'production', type: 'development',
}); });
await app.linkProjectToEnvironment('default', 'production'); await app.linkProjectToEnvironment('default', 'production');
@ -509,20 +510,31 @@ test('should not return duplicate entries when sorting by last seen', async () =
test('should search features by description', async () => { test('should search features by description', async () => {
const description = 'secretdescription'; const description = 'secretdescription';
await app.createFeature('my_feature_a'); await app.createFeature('my_feature_a');
await app.createFeature({ name: 'my_feature_b', description }); await app.createFeature({
name: 'my_feature_b',
description,
});
const { body } = await searchFeatures({ const { body } = await searchFeatures({
query: 'descr', query: 'descr',
}); });
expect(body).toMatchObject({ expect(body).toMatchObject({
features: [{ name: 'my_feature_b', description }], features: [
{
name: 'my_feature_b',
description,
},
],
}); });
}); });
test('should support multiple search values', async () => { test('should support multiple search values', async () => {
const description = 'secretdescription'; const description = 'secretdescription';
await app.createFeature('my_feature_a'); await app.createFeature('my_feature_a');
await app.createFeature({ name: 'my_feature_b', description }); await app.createFeature({
name: 'my_feature_b',
description,
});
await app.createFeature('my_feature_c'); await app.createFeature('my_feature_c');
const { body } = await searchFeatures({ const { body } = await searchFeatures({
@ -530,7 +542,10 @@ test('should support multiple search values', async () => {
}); });
expect(body).toMatchObject({ expect(body).toMatchObject({
features: [ features: [
{ name: 'my_feature_b', description }, {
name: 'my_feature_b',
description,
},
{ name: 'my_feature_c' }, { name: 'my_feature_c' },
], ],
}); });
@ -598,3 +613,38 @@ test('should search features by project with operators', async () => {
features: [{ name: 'my_feature_b' }], features: [{ name: 'my_feature_b' }],
}); });
}); });
test('should return segments in payload with no duplicates/nulls', async () => {
await app.createFeature('my_feature_a');
const { body: mySegment } = await app.createSegment({
name: 'my_segment_a',
constraints: [],
});
await stores.environmentStore.create({
name: 'development',
type: 'development',
});
await app.linkProjectToEnvironment('default', 'development');
await app.enableFeature('my_feature_a', 'development');
await app.addStrategyToFeatureEnv(
{
name: 'default',
segments: [mySegment.id],
},
DEFAULT_ENV,
'my_feature_a',
);
const { body } = await searchFeatures({});
expect(body).toMatchObject({
features: [
{
name: 'my_feature_a',
segments: [mySegment.name],
},
],
});
});

View File

@ -614,6 +614,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_tag as ft', 'feature_tag as ft',
'ft.feature_name', 'ft.feature_name',
'features.name', 'features.name',
)
.leftJoin(
'feature_strategies',
'feature_strategies.feature_name',
'features.name',
)
.leftJoin(
'feature_strategy_segment',
'feature_strategy_segment.feature_strategy_id',
'feature_strategies.id',
)
.leftJoin(
'segments',
'feature_strategy_segment.segment_id',
'segments.id',
); );
if (this.flagResolver.isEnabled('useLastSeenRefactor')) { if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
@ -645,6 +660,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value', 'ft.tag_value as tag_value',
'ft.tag_type as tag_type', 'ft.tag_type as tag_type',
'segments.name as segment_name',
] as (string | Raw<any> | Knex.QueryBuilder)[]; ] as (string | Raw<any> | Knex.QueryBuilder)[];
let lastSeenQuery = 'feature_environments.last_seen_at'; let lastSeenQuery = 'feature_environments.last_seen_at';
@ -735,7 +751,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
const rows = await finalQuery; const rows = await finalQuery;
if (rows.length > 0) { if (rows.length > 0) {
const overview = this.getFeatureOverviewData(rows); const overview = this.getAggregatedSearchData(rows);
const features = sortEnvironments(overview); const features = sortEnvironments(overview);
return { return {
features, features,
@ -861,6 +877,62 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
return []; return [];
} }
getAggregatedSearchData(rows): IFeatureOverview {
return rows.reduce((acc, row) => {
if (acc[row.feature_name] !== undefined) {
const environmentExists = acc[
row.feature_name
].environments.some(
(existingEnvironment) =>
existingEnvironment.name === row.environment,
);
if (!environmentExists) {
acc[row.feature_name].environments.push(
FeatureStrategiesStore.getEnvironment(row),
);
}
const segmentExists = acc[row.feature_name].segments.includes(
row.segment_name,
);
if (row.segment_name && !segmentExists) {
acc[row.feature_name].segments.push(row.segment_name);
}
if (this.isNewTag(acc[row.feature_name], row)) {
this.addTag(acc[row.feature_name], row);
}
} else {
acc[row.feature_name] = {
type: row.type,
description: row.description,
favorite: row.favorite,
name: row.feature_name,
createdAt: row.created_at,
stale: row.stale,
impressionData: row.impression_data,
lastSeenAt: row.last_seen_at,
environments: [FeatureStrategiesStore.getEnvironment(row)],
segments: row.segment_name ? [row.segment_name] : [],
};
if (this.isNewTag(acc[row.feature_name], row)) {
this.addTag(acc[row.feature_name], row);
}
}
const featureRow = acc[row.feature_name];
if (
featureRow.lastSeenAt === undefined ||
new Date(row.env_last_seen_at) >
new Date(featureRow.last_seen_at)
) {
featureRow.lastSeenAt = row.env_last_seen_at;
}
return acc;
}, {});
}
getFeatureOverviewData(rows): IFeatureOverview { getFeatureOverviewData(rows): IFeatureOverview {
return rows.reduce((acc, row) => { return rows.reduce((acc, row) => {
if (acc[row.feature_name] !== undefined) { if (acc[row.feature_name] !== undefined) {

View File

@ -56,15 +56,6 @@ const fetchSegmentStrategies = (
.expect(200) .expect(200)
.then((res) => res.body); .then((res) => res.body);
const createSegment = (
postData: object,
expectStatusCode = 201,
): Promise<unknown> =>
app.request
.post(SEGMENTS_BASE_PATH)
.send(postData)
.expect(expectStatusCode);
const updateSegment = ( const updateSegment = (
id: number, id: number,
postData: object, postData: object,
@ -94,7 +85,13 @@ const addSegmentsToStrategy = (
const mockFeatureToggle = () => ({ const mockFeatureToggle = () => ({
name: randomId(), name: randomId(),
strategies: [{ name: 'flexibleRollout', constraints: [], parameters: {} }], strategies: [
{
name: 'flexibleRollout',
constraints: [],
parameters: {},
},
],
}); });
const validateSegment = ( const validateSegment = (
@ -136,13 +133,38 @@ afterEach(async () => {
}); });
test('should validate segments', async () => { test('should validate segments', async () => {
await createSegment({ something: 'a' }, 400); await app.createSegment({ something: 'a' }, 400);
await createSegment({ name: randomId(), something: 'b' }, 400); await app.createSegment(
await createSegment({ name: randomId(), constraints: 'b' }, 400); {
await createSegment({ constraints: [] }, 400); name: randomId(),
await createSegment({ name: randomId(), constraints: [{}] }, 400); something: 'b',
await createSegment({ name: randomId(), constraints: [] }); },
await createSegment({ name: randomId(), description: '', constraints: [] }); 400,
);
await app.createSegment(
{
name: randomId(),
constraints: 'b',
},
400,
);
await app.createSegment({ constraints: [] }, 400);
await app.createSegment(
{
name: randomId(),
constraints: [{}],
},
400,
);
await app.createSegment({
name: randomId(),
constraints: [],
});
await app.createSegment({
name: randomId(),
description: '',
constraints: [],
});
}); });
test('should fail on missing properties', async () => { test('should fail on missing properties', async () => {
@ -159,23 +181,38 @@ test('should fail on missing properties', async () => {
}); });
test('should create segments', async () => { test('should create segments', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
await createSegment({ name: 'c', constraints: [] }); name: 'a',
await createSegment({ name: 'b', constraints: [] }); constraints: [],
});
await app.createSegment({
name: 'c',
constraints: [],
});
await app.createSegment({
name: 'b',
constraints: [],
});
const segments = await fetchSegments(); const segments = await fetchSegments();
expect(segments.map((s) => s.name)).toEqual(['a', 'b', 'c']); expect(segments.map((s) => s.name)).toEqual(['a', 'b', 'c']);
}); });
test('should update segments', async () => { test('should update segments', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const [segmentA] = await fetchSegments(); const [segmentA] = await fetchSegments();
expect(segmentA.id).toBeGreaterThan(0); expect(segmentA.id).toBeGreaterThan(0);
expect(segmentA.name).toEqual('a'); expect(segmentA.name).toEqual('a');
expect(segmentA.createdAt).toBeDefined(); expect(segmentA.createdAt).toBeDefined();
expect(segmentA.constraints.length).toEqual(0); expect(segmentA.constraints.length).toEqual(0);
await updateSegment(segmentA.id, { ...segmentA, name: 'b' }); await updateSegment(segmentA.id, {
...segmentA,
name: 'b',
});
const [segmentB] = await fetchSegments(); const [segmentB] = await fetchSegments();
expect(segmentB.id).toEqual(segmentA.id); expect(segmentB.id).toEqual(segmentA.id);
@ -185,15 +222,29 @@ test('should update segments', async () => {
}); });
test('should update segment constraints', async () => { test('should update segment constraints', async () => {
const constraintA = { contextName: 'a', operator: 'IN', values: ['x'] }; const constraintA = {
const constraintB = { contextName: 'b', operator: 'IN', values: ['y'] }; contextName: 'a',
await createSegment({ name: 'a', constraints: [constraintA] }); operator: 'IN',
values: ['x'],
};
const constraintB = {
contextName: 'b',
operator: 'IN',
values: ['y'],
};
await app.createSegment({
name: 'a',
constraints: [constraintA],
});
const [segmentA] = await fetchSegments(); const [segmentA] = await fetchSegments();
expect(segmentA.constraints).toEqual([constraintA]); expect(segmentA.constraints).toEqual([constraintA]);
await app.request await app.request
.put(`${SEGMENTS_BASE_PATH}/${segmentA.id}`) .put(`${SEGMENTS_BASE_PATH}/${segmentA.id}`)
.send({ ...segmentA, constraints: [constraintB, constraintA] }) .send({
...segmentA,
constraints: [constraintB, constraintA],
})
.expect(204); .expect(204);
const [segmentB] = await fetchSegments(); const [segmentB] = await fetchSegments();
@ -201,7 +252,10 @@ test('should update segment constraints', async () => {
}); });
test('should delete segments', async () => { test('should delete segments', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const segments = await fetchSegments(); const segments = await fetchSegments();
expect(segments.length).toEqual(1); expect(segments.length).toEqual(1);
@ -213,7 +267,10 @@ test('should delete segments', async () => {
}); });
test('should not delete segments used by strategies', async () => { test('should not delete segments used by strategies', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(app, toggle); await createFeatureToggle(app, toggle);
const [segment] = await fetchSegments(); const [segment] = await fetchSegments();
@ -238,9 +295,18 @@ test('should not delete segments used by strategies', async () => {
}); });
test('should list strategies by segment', async () => { test('should list strategies by segment', async () => {
await createSegment({ name: 'S1', constraints: [] }); await app.createSegment({
await createSegment({ name: 'S2', constraints: [] }); name: 'S1',
await createSegment({ name: 'S3', constraints: [] }); constraints: [],
});
await app.createSegment({
name: 'S2',
constraints: [],
});
await app.createSegment({
name: 'S3',
constraints: [],
});
const toggle1 = mockFeatureToggle(); const toggle1 = mockFeatureToggle();
const toggle2 = mockFeatureToggle(); const toggle2 = mockFeatureToggle();
const toggle3 = mockFeatureToggle(); const toggle3 = mockFeatureToggle();
@ -305,9 +371,18 @@ test('should list strategies by segment', async () => {
}); });
test('should list segments by strategy', async () => { test('should list segments by strategy', async () => {
await createSegment({ name: 'S1', constraints: [] }); await app.createSegment({
await createSegment({ name: 'S2', constraints: [] }); name: 'S1',
await createSegment({ name: 'S3', constraints: [] }); constraints: [],
});
await app.createSegment({
name: 'S2',
constraints: [],
});
await app.createSegment({
name: 'S3',
constraints: [],
});
const toggle1 = mockFeatureToggle(); const toggle1 = mockFeatureToggle();
const toggle2 = mockFeatureToggle(); const toggle2 = mockFeatureToggle();
const toggle3 = mockFeatureToggle(); const toggle3 = mockFeatureToggle();
@ -376,7 +451,10 @@ test('should list segments by strategy', async () => {
test('should reject duplicate segment names', async () => { test('should reject duplicate segment names', async () => {
await validateSegment({ name: 'a' }); await validateSegment({ name: 'a' });
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
await validateSegment({ name: 'a' }, 409); await validateSegment({ name: 'a' }, 409);
await validateSegment({ name: 'b' }); await validateSegment({ name: 'b' });
}); });
@ -388,31 +466,75 @@ test('should reject empty segment names', async () => {
}); });
test('should reject duplicate segment names on create', async () => { test('should reject duplicate segment names on create', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
await createSegment({ name: 'a', constraints: [] }, 409); name: 'a',
constraints: [],
});
await app.createSegment(
{
name: 'a',
constraints: [],
},
409,
);
await validateSegment({ name: 'b' }); await validateSegment({ name: 'b' });
}); });
test('should reject duplicate segment names on update', async () => { test('should reject duplicate segment names on update', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
await createSegment({ name: 'b', constraints: [] }); name: 'a',
constraints: [],
});
await app.createSegment({
name: 'b',
constraints: [],
});
const [segmentA, segmentB] = await fetchSegments(); const [segmentA, segmentB] = await fetchSegments();
await updateSegment(segmentA.id, { name: 'b', constraints: [] }, 409); await updateSegment(
await updateSegment(segmentB.id, { name: 'a', constraints: [] }, 409); segmentA.id,
await updateSegment(segmentA.id, { name: 'a', constraints: [] }); {
await updateSegment(segmentA.id, { name: 'c', constraints: [] }); 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 () => { test('Should anonymise createdBy field if anonymiseEventLog flag is set', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
await createSegment({ name: 'b', constraints: [] }); name: 'a',
constraints: [],
});
await app.createSegment({
name: 'b',
constraints: [],
});
const segments = await fetchSegments(); const segments = await fetchSegments();
expect(segments).toHaveLength(2); expect(segments).toHaveLength(2);
expect(segments[0].createdBy).toContain('unleash.run'); expect(segments[0].createdBy).toContain('unleash.run');
}); });
test('Should show usage in features and projects', async () => { test('Should show usage in features and projects', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(app, toggle); await createFeatureToggle(app, toggle);
const [segment] = await fetchSegments(); const [segment] = await fetchSegments();
@ -427,7 +549,12 @@ test('Should show usage in features and projects', async () => {
await addSegmentsToStrategy([segment.id], feature.strategies[0].id); await addSegmentsToStrategy([segment.id], feature.strategies[0].id);
const segments = await fetchSegments(); const segments = await fetchSegments();
expect(segments).toMatchObject([{ usedInFeatures: 1, usedInProjects: 1 }]); expect(segments).toMatchObject([
{
usedInFeatures: 1,
usedInProjects: 1,
},
]);
}); });
describe('detect strategy usage in change requests', () => { describe('detect strategy usage in change requests', () => {
@ -497,7 +624,10 @@ describe('detect strategy usage in change requests', () => {
}); });
test('should not delete segments used by strategies in CRs', async () => { test('should not delete segments used by strategies in CRs', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(enterpriseApp, toggle); await createFeatureToggle(enterpriseApp, toggle);
const [segment] = await enterpriseFetchSegments(); const [segment] = await enterpriseFetchSegments();
@ -538,7 +668,10 @@ describe('detect strategy usage in change requests', () => {
}); });
test('Should show segment usage in addStrategy events', async () => { test('Should show segment usage in addStrategy events', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(enterpriseApp, toggle); await createFeatureToggle(enterpriseApp, toggle);
const [segment] = await enterpriseFetchSegments(); const [segment] = await enterpriseFetchSegments();
@ -585,7 +718,10 @@ describe('detect strategy usage in change requests', () => {
}); });
test('Should show segment usage in updateStrategy events', async () => { test('Should show segment usage in updateStrategy events', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(enterpriseApp, toggle); await createFeatureToggle(enterpriseApp, toggle);
const [segment] = await enterpriseFetchSegments(); const [segment] = await enterpriseFetchSegments();
@ -641,7 +777,10 @@ describe('detect strategy usage in change requests', () => {
}); });
test('If a segment is used in an existing strategy and in a CR for the same strategy, the strategy should be listed both places', async () => { test('If a segment is used in an existing strategy and in a CR for the same strategy, the strategy should be listed both places', async () => {
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({
name: 'a',
constraints: [],
});
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(enterpriseApp, toggle); await createFeatureToggle(enterpriseApp, toggle);
const [segment] = await enterpriseFetchSegments(); const [segment] = await enterpriseFetchSegments();
@ -697,7 +836,7 @@ describe('detect strategy usage in change requests', () => {
// because they use the same db, we can use the regular app // because they use the same db, we can use the regular app
// (through `createSegment` and `createFeatureToggle`) to // (through `createSegment` and `createFeatureToggle`) to
// create the segment and the flag // create the segment and the flag
await createSegment({ name: 'a', constraints: [] }); await app.createSegment({ name: 'a', constraints: [] });
const toggle = mockFeatureToggle(); const toggle = mockFeatureToggle();
await createFeatureToggle(app, toggle); await createFeatureToggle(app, toggle);
const [segment] = await enterpriseFetchSegments(); const [segment] = await enterpriseFetchSegments();

View File

@ -99,6 +99,8 @@ export interface IUnleashHttpAPI {
): supertest.Test; ): supertest.Test;
getRecordedEvents(): supertest.Test; getRecordedEvents(): supertest.Test;
createSegment(postData: object, expectStatusCode?: number): supertest.Test;
} }
function httpApis( function httpApis(
@ -260,6 +262,16 @@ function httpApis(
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(expectedResponseCode); .expect(expectedResponseCode);
}, },
createSegment(
postData: object,
expectedResponseCode = 201,
): supertest.Test {
return request
.post(`/api/admin/segments`)
.send(postData)
.set('Content-Type', 'application/json')
.expect(expectedResponseCode);
},
getRecordedEvents( getRecordedEvents(
project: string | null = null, project: string | null = null,