From 1c8fab63e247dbd2419233edb4f7816e6f005b1a Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 27 Oct 2023 08:54:03 +0200 Subject: [PATCH] feat: filter by environment status (#5165) --- .../feature-search-controller.ts | 10 ++++++- .../feature-search/feature-search-service.ts | 10 ++----- .../feature-search/feature.search.e2e.test.ts | 28 +++++++++++++++++++ .../feature-toggle-strategies-store.ts | 18 ++++++++++++ .../feature-toggle-strategies-store-type.ts | 1 + .../spec/feature-search-query-parameters.ts | 15 +++++++++- src/test/e2e/helpers/test-helper.ts | 20 +++++++++++++ 7 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 0829ea4e83..d04bc1eea6 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -72,17 +72,25 @@ export default class FeatureSearchController extends Controller { res: Response, ): Promise { if (this.config.flagResolver.isEnabled('featureSearchAPI')) { - const { query, projectId, type, tag } = req.query; + const { query, projectId, type, tag, status } = req.query; const userId = req.user.id; const normalizedTag = tag ?.map((tag) => tag.split(':')) .filter((tag) => tag.length === 2); + const normalizedStatus = status + ?.map((tag) => tag.split(':')) + .filter( + (tag) => + tag.length === 2 && + ['enabled', 'disabled'].includes(tag[1]), + ); const features = await this.featureSearchService.search({ query, projectId, type, userId, tag: normalizedTag, + status: normalizedStatus, }); res.json({ features }); } else { diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 1aca623dbd..c7de4b0024 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -20,13 +20,9 @@ export class FeatureSearchService { } async search(params: IFeatureSearchParams) { - const features = await this.featureStrategiesStore.searchFeatures({ - projectId: params.projectId, - query: params.query, - userId: params.userId, - type: params.type, - tag: params.tag, - }); + const features = await this.featureStrategiesStore.searchFeatures( + params, + ); return features; } diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 007477404c..3bd904e109 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -57,6 +57,18 @@ const filterFeaturesByTag = async (tags: string[], expectedCode = 200) => { .expect(expectedCode); }; +const filterFeaturesByEnvironmentStatus = async ( + environmentStatuses: string[], + expectedCode = 200, +) => { + const statuses = environmentStatuses + .map((status) => `status[]=${status}`) + .join('&'); + return app.request + .get(`/api/admin/search/features?${statuses}`) + .expect(expectedCode); +}; + const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => { return app.request.get(`/api/admin/search/features`).expect(expectedCode); }; @@ -99,6 +111,22 @@ test('should filter features by tag', async () => { }); }); +test('should filter features by environment status', async () => { + await app.createFeature('my_feature_a'); + await app.createFeature('my_feature_b'); + await app.enableFeature('my_feature_a', 'default'); + + const { body } = await filterFeaturesByEnvironmentStatus([ + 'default:enabled', + 'nonexistentEnv:disabled', + 'default:wrongStatus', + ]); + + expect(body).toMatchObject({ + features: [{ name: 'my_feature_a' }], + }); +}); + test('filter with invalid tag should ignore filter', async () => { await app.createFeature('my_feature_a'); await app.createFeature('my_feature_b'); diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts index 8575330fdf..2b015528a6 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -522,6 +522,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { query: queryString, type, tag, + status, }: IFeatureSearchParams): Promise { let query = this.db('features'); if (projectId) { @@ -553,6 +554,23 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { if (type) { query = query.whereIn('features.type', type); } + + if (status && status.length > 0) { + query = query.where((builder) => { + for (const [envName, envStatus] of status) { + builder.orWhere(function () { + this.where( + 'feature_environments.environment', + envName, + ).andWhere( + 'feature_environments.enabled', + envStatus === 'enabled' ? true : false, + ); + }); + } + }); + } + query = query .modify(FeatureToggleStore.filterByArchived, false) .leftJoin( diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index 32acf6dbda..575203857a 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -27,6 +27,7 @@ export interface IFeatureSearchParams { projectId?: string; type?: string[]; tag?: string[][]; + status?: string[][]; } export interface IFeatureStrategiesStore diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 7b92732c26..2c05c89db4 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -41,7 +41,20 @@ export const featureSearchQueryParameters = [ }, }, description: - 'The list of feature tags to filter by. Feature tag has to specify type and value joined with a colon.', + 'The list of feature tags to filter by. Feature tag has to specify a type and a value joined with a colon.', + in: 'query', + }, + { + name: 'status', + schema: { + type: 'array', + items: { + type: 'string', + example: 'production:enabled', + }, + }, + description: + 'The list of feature environment status to filter by. Feature environment has to specify a name and a status joined with a colon.', in: 'query', }, ] as const; diff --git a/src/test/e2e/helpers/test-helper.ts b/src/test/e2e/helpers/test-helper.ts index e7b0f7aeaf..23d4d09b27 100644 --- a/src/test/e2e/helpers/test-helper.ts +++ b/src/test/e2e/helpers/test-helper.ts @@ -47,6 +47,13 @@ export interface IUnleashHttpAPI { expectedResponseCode?: number, ): supertest.Test; + enableFeature( + feature: string, + environment: string, + project?: string, + expectedResponseCode?: number, + ): supertest.Test; + getFeatures(name?: string, expectedResponseCode?: number): supertest.Test; getProjectFeatures( @@ -219,6 +226,19 @@ function httpApis( .set('Content-Type', 'application/json') .expect(expectedResponseCode); }, + + enableFeature( + feature: string, + environment, + project = 'default', + expectedResponseCode = 200, + ): supertest.Test { + return request + .post( + `/api/admin/projects/${project}/features/${feature}/environments/${environment}/on`, + ) + .expect(expectedResponseCode); + }, }; }