diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ffe40fac58..dad0d244ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ # Add reviewers for the most sensitive folders /.github/ egor.zalenski@softeq.com artem.horuzhenko@softeq.com /.circleci/ egor.zalenski@softeq.com artem.horuzhenko@softeq.com +/redisinsight/api/config/features-config.json viktar.starastsenka@redis.com egor.zalenski@softeq.com artem.horuzhenko@softeq.com diff --git a/.gitignore b/.gitignore index 525c7ce01c..98db37c172 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ vendor # E2E tests report /tests/e2e/report +/tests/e2e/results +/tests/e2e/remote /tests/e2e/.redisinsight-v2 # Parcel diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index c55a6d0d5b..1daf46c1e6 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -195,7 +195,7 @@ export default { }, ], connections: { - timeout: parseInt(process.env.CONNECTIONS_TIMEOUT_DEFAULT, 10) || 30 * 1_000 // 30 sec + timeout: parseInt(process.env.CONNECTIONS_TIMEOUT_DEFAULT, 10) || 30 * 1_000, // 30 sec }, redisStack: { id: process.env.BUILD_TYPE === 'REDIS_STACK' ? process.env.REDIS_STACK_DATABASE_ID || 'redis-stack' : undefined, @@ -203,4 +203,10 @@ export default { host: process.env.REDIS_STACK_DATABASE_HOST, port: process.env.REDIS_STACK_DATABASE_PORT, }, + features_config: { + url: process.env.RI_FEATURES_CONFIG_URL + // eslint-disable-next-line max-len + || 'https://raw.githubusercontent.com/RedisInsight/RedisInsight/main/redisinsight/api/config/features-config.json', + syncInterval: parseInt(process.env.RI_FEATURES_CONFIG_SYNC_INTERVAL, 10) || 1_000 * 60 * 60 * 4, // 4h + }, }; diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json new file mode 100644 index 0000000000..17fb431396 --- /dev/null +++ b/redisinsight/api/config/features-config.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "features": { + "insightsRecommendations": { + "flag": true, + "perc": [], + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + } +} diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index a2fb61e5d0..04feb42f8e 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -15,6 +15,8 @@ import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-history.entity'; import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -40,6 +42,8 @@ const ormConfig = { BrowserHistoryEntity, SshOptionsEntity, CustomTutorialEntity, + FeatureEntity, + FeaturesConfigEntity, ], migrations, }; diff --git a/redisinsight/api/config/test.ts b/redisinsight/api/config/test.ts index 7b087a949c..f5214e8086 100644 --- a/redisinsight/api/config/test.ts +++ b/redisinsight/api/config/test.ts @@ -1,12 +1,21 @@ export default { server: { env: 'test', - requestTimeout: 1000, + requestTimeout: parseInt(process.env.REQUEST_TIMEOUT, 10) || 1000, + }, + db: { + synchronize: process.env.DB_SYNC ? process.env.DB_SYNC === 'true' : true, + migrationsRun: process.env.DB_MIGRATIONS ? process.env.DB_MIGRATIONS === 'true' : false, }, profiler: { logFileIdleThreshold: parseInt(process.env.PROFILER_LOG_FILE_IDLE_THRESHOLD, 10) || 1000 * 2, // 3sec }, notifications: { - updateUrl: 'https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json', + updateUrl: process.env.NOTIFICATION_UPDATE_URL + || 'https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json', + }, + features_config: { + url: process.env.RI_FEATURES_CONFIG_URL + || 'http://localhost:5551/remote/features-config.json', }, }; diff --git a/redisinsight/api/migration/1684931530343-feature.ts b/redisinsight/api/migration/1684931530343-feature.ts new file mode 100644 index 0000000000..5c1f31ecf2 --- /dev/null +++ b/redisinsight/api/migration/1684931530343-feature.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Feature1684931530343 implements MigrationInterface { + name = 'Feature1684931530343' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "features" ("name" varchar PRIMARY KEY NOT NULL, "flag" boolean NOT NULL)`); + await queryRunner.query(`CREATE TABLE "features_config" ("id" varchar PRIMARY KEY NOT NULL, "controlNumber" float, "data" varchar NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "features_config"`); + await queryRunner.query(`DROP TABLE "features"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 3693bbcd5a..11ae966a9d 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -31,6 +31,7 @@ import { databaseCompressor1678182722874 } from './1678182722874-database-compre import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; import { databaseRecommendations1681900503586 } from './1681900503586-database-recommendations'; import { databaseRecommendationParams1683006064293 } from './1683006064293-database-recommendation-params'; +import { Feature1684931530343 } from './1684931530343-feature'; export default [ initialMigration1614164490968, @@ -66,4 +67,5 @@ export default [ customTutorials1677135091633, databaseRecommendations1681900503586, databaseRecommendationParams1683006064293, + Feature1684931530343, ]; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 367a4f63d0..3a3936e142 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -31,7 +31,7 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand -w 1", "test:e2e": "jest --config ./test/jest-e2e.json -w 1", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./config/ormconfig.ts", - "test:api": "ts-mocha --paths -p test/api/api.tsconfig.json --config ./test/api/.mocharc.yml", + "test:api": "cross-env NODE_ENV=test ts-mocha --paths -p test/api/api.tsconfig.json --config ./test/api/.mocharc.yml", "test:api:cov": "nyc --reporter=html --reporter=text --reporter=text-summary yarn run test:api", "test:api:ci:cov": "nyc -r text -r text-summary -r html yarn run test:api --reporter mocha-multi-reporters --reporter-options configFile=test/api/reporters.json && nyc merge .nyc_output ./coverage/test-run-coverage.json", "typeorm:migrate": "cross-env NODE_ENV=staging yarn typeorm migration:generate ./migration/migration", diff --git a/redisinsight/api/src/__mocks__/common.ts b/redisinsight/api/src/__mocks__/common.ts index 92aa89914d..0772ae4d1e 100644 --- a/redisinsight/api/src/__mocks__/common.ts +++ b/redisinsight/api/src/__mocks__/common.ts @@ -61,6 +61,7 @@ export const mockRepository = jest.fn(() => ({ save: jest.fn(), insert: jest.fn(), update: jest.fn(), + upsert: jest.fn(), delete: jest.fn(), remove: jest.fn(), createQueryBuilder: mockCreateQueryBuilder, diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts new file mode 100644 index 0000000000..2d30232a30 --- /dev/null +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -0,0 +1,219 @@ +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { + FeatureConfig, + FeatureConfigFilter, FeatureConfigFilterAnd, FeatureConfigFilterOr, + FeaturesConfig, + FeaturesConfigData, +} from 'src/modules/feature/model/features-config'; +import { classToClass } from 'src/utils'; +import { Feature } from 'src/modules/feature/model/feature'; +import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; +import { mockAppSettings } from 'src/__mocks__/app-settings'; +import config from 'src/utils/config'; +import { KnownFeatures } from 'src/modules/feature/constants'; +import * as defaultConfig from '../../config/features-config.json'; + +export const mockFeaturesConfigId = '1'; +export const mockFeaturesConfigVersion = defaultConfig.version + 0.111; +export const mockControlNumber = 7.68; +export const mockControlGroup = '7'; + +export const mockFeaturesConfigJson = { + version: mockFeaturesConfigVersion, + features: { + [KnownFeatures.InsightsRecommendations]: { + perc: [[1.25, 8.45]], + flag: true, + filters: [ + { + name: 'agreements.analytics', + value: true, + cond: 'eq', + }, + ], + }, + }, +}; + +export const mockFeaturesConfigJsonComplex = { + ...mockFeaturesConfigJson, + features: { + [KnownFeatures.InsightsRecommendations]: { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], + filters: [ + { + or: [ + { + name: 'settings.testValue', + value: 'test', + cond: 'eq', + }, + { + and: [ + { + name: 'agreements.analytics', + value: true, + cond: 'eq', + }, + { + or: [ + { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: 'eq', + }, + { + name: 'settings.batchSize', + value: mockAppSettings.batchSize, + cond: 'eq', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, +}; + +export const mockFeaturesConfigData = Object.assign(new FeaturesConfigData(), { + ...mockFeaturesConfigJson, + features: new Map(Object.entries({ + [KnownFeatures.InsightsRecommendations]: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], + filters: [ + Object.assign(new FeatureConfigFilter(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + }), + ], + }), + })), +}); + +export const mockFeaturesConfigDataComplex = Object.assign(new FeaturesConfigData(), { + ...mockFeaturesConfigJson, + features: new Map(Object.entries({ + [KnownFeatures.InsightsRecommendations]: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], + filters: [ + Object.assign(new FeatureConfigFilterOr(), { + or: [ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.testValue', + value: 'test', + cond: 'eq', + }), + Object.assign(new FeatureConfigFilterAnd(), { + and: [ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: 'eq', + }), + Object.assign(new FeatureConfigFilterOr(), { + or: [ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: 'eq', + }), + Object.assign(new FeatureConfigFilter(), { + name: 'settings.batchSize', + value: mockAppSettings.batchSize, + cond: 'eq', + }), + ], + }), + ], + }), + ], + }), + ], + }), + })), +}); + +export const mockFeaturesConfig = Object.assign(new FeaturesConfig(), { + controlNumber: mockControlNumber, + data: mockFeaturesConfigData, +}); + +export const mockFeaturesConfigComplex = Object.assign(new FeaturesConfig(), { + controlNumber: mockControlNumber, + data: mockFeaturesConfigDataComplex, +}); + +export const mockFeaturesConfigEntity = Object.assign(new FeaturesConfigEntity(), { + ...classToClass(FeaturesConfigEntity, mockFeaturesConfig), + id: mockFeaturesConfigId, +}); + +export const mockFeaturesConfigEntityComplex = Object.assign(new FeaturesConfigEntity(), { + ...classToClass(FeaturesConfigEntity, mockFeaturesConfigComplex), + id: mockFeaturesConfigId, +}); + +export const mockFeature = Object.assign(new Feature(), { + name: KnownFeatures.InsightsRecommendations, + flag: true, +}); + +export const mockUnknownFeature = Object.assign(new Feature(), { + name: 'unknown', + flag: true, +}); + +export const mockFeatureEntity = Object.assign(new FeatureEntity(), { + id: 'lr-1', + name: KnownFeatures.InsightsRecommendations, + flag: true, +}); + +export const mockServerState = { + settings: mockAppSettings, + agreements: mockAppSettings.agreements, + config: config.get(), + env: process.env, +}; + +export const mockFeaturesConfigRepository = jest.fn(() => ({ + getOrCreate: jest.fn().mockResolvedValue(mockFeaturesConfig), + update: jest.fn().mockResolvedValue(mockFeaturesConfig), +})); + +export const mockFeatureRepository = jest.fn(() => ({ + get: jest.fn().mockResolvedValue(mockFeature), + upsert: jest.fn().mockResolvedValue({ updated: 1 }), + list: jest.fn().mockResolvedValue([mockFeature]), + delete: jest.fn().mockResolvedValue({ deleted: 1 }), +})); + +export const mockFeaturesConfigService = jest.fn(() => ({ + sync: jest.fn(), + getControlInfo: jest.fn().mockResolvedValue({ + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }), +})); + +export const mockFeatureService = jest.fn(() => ({ + isFeatureEnabled: jest.fn().mockResolvedValue(true), +})); + +export const mockFeatureAnalytics = jest.fn(() => ({ + sendFeatureFlagConfigUpdated: jest.fn(), + sendFeatureFlagConfigUpdateError: jest.fn(), + sendFeatureFlagInvalidRemoteConfig: jest.fn(), + sendFeatureFlagRecalculated: jest.fn(), +})); + +export const mockInsightsRecommendationsFlagStrategy = { + calculate: jest.fn().mockResolvedValue(true), +}; + +export const mockFeatureFlagProvider = jest.fn(() => ({ + getStrategy: jest.fn().mockResolvedValue(mockInsightsRecommendationsFlagStrategy), + calculate: jest.fn().mockResolvedValue(true), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index ca6c73f6ad..4298564992 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -22,3 +22,4 @@ export * from './redis-client'; export * from './ssh'; export * from './browser-history'; export * from './database-recommendation'; +export * from './feature'; diff --git a/redisinsight/api/src/common/decorators/index.ts b/redisinsight/api/src/common/decorators/index.ts index 432b2d6ea1..eb2f908a01 100644 --- a/redisinsight/api/src/common/decorators/index.ts +++ b/redisinsight/api/src/common/decorators/index.ts @@ -4,3 +4,5 @@ export * from './default'; export * from './data-as-json-string.decorator'; export * from './session'; export * from './client-metadata'; +export * from './object-as-map.decorator'; +export * from './is-multi-number.decorator'; diff --git a/redisinsight/api/src/common/decorators/is-multi-number.decorator.ts b/redisinsight/api/src/common/decorators/is-multi-number.decorator.ts new file mode 100644 index 0000000000..49ff74bdde --- /dev/null +++ b/redisinsight/api/src/common/decorators/is-multi-number.decorator.ts @@ -0,0 +1,17 @@ +import { + registerDecorator, + ValidationOptions, +} from 'class-validator'; +import { MultiNumberValidator } from 'src/common/validators'; + +export function IsMultiNumber(validationOptions?: ValidationOptions) { + return (object: any, propertyName: string) => { + registerDecorator({ + name: 'IsMultiNumber', + target: object.constructor, + propertyName, + options: validationOptions, + validator: MultiNumberValidator, + }); + }; +} diff --git a/redisinsight/api/src/common/decorators/object-as-map.decorator.ts b/redisinsight/api/src/common/decorators/object-as-map.decorator.ts new file mode 100644 index 0000000000..86ae6ecce4 --- /dev/null +++ b/redisinsight/api/src/common/decorators/object-as-map.decorator.ts @@ -0,0 +1,35 @@ +import { forEach } from 'lodash'; +import { applyDecorators } from '@nestjs/common'; +import { classToPlain, plainToClass, Transform } from 'class-transformer'; +import { ClassType } from 'class-transformer/ClassTransformer'; + +export function ObjectAsMap(targetClass: ClassType) { + return applyDecorators( + Transform((object = {}): Map => { + const result = new Map(); + + try { + forEach(object, (value, key) => { + result.set(key, plainToClass(targetClass, value)); + }); + + return result; + } catch (e) { + return result; + } + }, { toClassOnly: true }), + Transform((map): object => { + try { + const result = {}; + + forEach(Array.from(map), ([key, value]) => { + result[key] = classToPlain(value); + }); + + return result; + } catch (e) { + return undefined; + } + }, { toPlainOnly: true }), + ); +} diff --git a/redisinsight/api/src/common/validators/index.ts b/redisinsight/api/src/common/validators/index.ts index dd557f817e..fb46f5b360 100644 --- a/redisinsight/api/src/common/validators/index.ts +++ b/redisinsight/api/src/common/validators/index.ts @@ -1,2 +1,3 @@ export * from './redis-string.validator'; export * from './zset-score.validator'; +export * from './multi-number.validator'; diff --git a/redisinsight/api/src/common/validators/multi-number.validator.ts b/redisinsight/api/src/common/validators/multi-number.validator.ts new file mode 100644 index 0000000000..18ab7f2cd3 --- /dev/null +++ b/redisinsight/api/src/common/validators/multi-number.validator.ts @@ -0,0 +1,27 @@ +import { isNumber, isArray } from 'lodash'; +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'MultiNumberValidator', async: true }) +export class MultiNumberValidator implements ValidatorConstraintInterface { + async validate(value: any) { + if (!isArray(value)) { + return false; + } + + return value.every((numbersArray) => { + if (!isArray(numbersArray)) { + return false; + } + + return numbersArray.every(isNumber); + }); + } + + defaultMessage(args: ValidationArguments) { + return `${args.property || 'field'} must be a multidimensional array of numbers`; + } +} diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index a85dbc5ac8..9d39221de1 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -66,6 +66,12 @@ export enum TelemetryEvents { BulkActionsStopped = 'BULK_ACTIONS_STOPPED', BulkActionsSucceed = 'BULK_ACTIONS_SUCCEED', BulkActionsFailed = 'BULK_ACTIONS_FAILED', + + // Feature + FeatureFlagConfigUpdated = 'FEATURE_FLAG_CONFIG_UPDATED', + FeatureFlagConfigUpdateError = 'FEATURE_FLAG_CONFIG_UPDATE_ERROR', + FeatureFlagInvalidRemoteConfig = 'FEATURE_FLAG_INVALID_REMOTE_CONFIG', + FeatureFlagRecalculated = 'FEATURE_FLAG_RECALCULATED', } export enum CommandType { diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index 100ec29236..eb5167d60c 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -9,6 +9,7 @@ import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; import { SshModule } from 'src/modules/ssh/ssh.module'; import { NestjsFormDataModule } from 'nestjs-form-data'; +import { FeatureModule } from 'src/modules/feature/feature.module'; @Global() @Module({ @@ -23,6 +24,7 @@ import { NestjsFormDataModule } from 'nestjs-form-data'; DatabaseRecommendationModule.register(), SshModule, NestjsFormDataModule, + FeatureModule.register(), ], exports: [ EncryptionModule, @@ -33,6 +35,7 @@ import { NestjsFormDataModule } from 'nestjs-form-data'; RedisModule, SshModule, NestjsFormDataModule, + FeatureModule, ], }) export class CoreModule {} diff --git a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts index 363b3ce064..9517153333 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.spec.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mockAppSettings, - mockAppSettingsWithoutPermissions, + mockAppSettingsWithoutPermissions, mockControlGroup, mockControlNumber, mockSettingsService, MockType, } from 'src/__mocks__'; @@ -53,7 +53,13 @@ describe('AnalyticsService', () => { describe('initialize', () => { it('should set anonymousId', () => { - service.initialize({ anonymousId: mockAnonymousId, sessionId, appType: AppType.Electron }); + service.initialize({ + anonymousId: mockAnonymousId, + sessionId, + appType: AppType.Electron, + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }); const anonymousId = service.getAnonymousId(); @@ -64,7 +70,13 @@ describe('AnalyticsService', () => { describe('sendEvent', () => { beforeEach(() => { mockAnalyticsTrack = jest.fn(); - service.initialize({ anonymousId: mockAnonymousId, sessionId, appType: AppType.Electron }); + service.initialize({ + anonymousId: mockAnonymousId, + sessionId, + appType: AppType.Electron, + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }); }); it('should send event with anonymousId if permission are granted', async () => { settingsService.getAppSettings.mockResolvedValue(mockAppSettings); @@ -81,6 +93,8 @@ describe('AnalyticsService', () => { event: TelemetryEvents.ApplicationStarted, properties: { buildType: AppType.Electron, + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, }, }); }); @@ -110,6 +124,8 @@ describe('AnalyticsService', () => { event: TelemetryEvents.ApplicationStarted, properties: { buildType: AppType.Electron, + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, }, }); }); diff --git a/redisinsight/api/src/modules/analytics/analytics.service.ts b/redisinsight/api/src/modules/analytics/analytics.service.ts index 83f2c16201..9284cb33df 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.ts @@ -19,6 +19,8 @@ export interface ITelemetryInitEvent { anonymousId: string; sessionId: number; appType: string; + controlNumber: number; + controlGroup: string; } @Injectable() @@ -29,6 +31,10 @@ export class AnalyticsService { private appType: string = 'unknown'; + private controlNumber: number = -1; + + private controlGroup: string = '-1'; + private analytics; constructor( @@ -41,10 +47,14 @@ export class AnalyticsService { @OnEvent(AppAnalyticsEvents.Initialize) public initialize(payload: ITelemetryInitEvent) { - const { anonymousId, sessionId, appType } = payload; + const { + anonymousId, sessionId, appType, controlNumber, controlGroup, + } = payload; this.sessionId = sessionId; this.anonymousId = anonymousId; this.appType = appType; + this.controlGroup = controlGroup; + this.controlNumber = controlNumber; this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey, { flushInterval: ANALYTICS_CONFIG.flushInterval, }); @@ -75,6 +85,8 @@ export class AnalyticsService { properties: { ...eventData, buildType: this.appType, + controlNumber: this.controlNumber, + controlGroup: this.controlGroup, }, }); } diff --git a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts index c72649955c..03819f0670 100644 --- a/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts +++ b/redisinsight/api/src/modules/bulk-actions/models/bulk-action.spec.ts @@ -37,7 +37,7 @@ const mockCreateBulkActionDto = { const mockOverview = { ...mockCreateBulkActionDto, - duration: 0, + duration: jasmine.any(Number), filter: { match: '*', type: null }, progress: { scanned: 0, diff --git a/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.spec.ts b/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.spec.ts index 68b22e4dd3..9dc405fbbb 100644 --- a/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.spec.ts +++ b/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { RecommendationScanner } from 'src/modules/database-recommendation/scanner/recommendations.scanner'; import { RecommendationProvider } from 'src/modules/database-recommendation/scanner/recommendation.provider'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { mockFeatureService, MockType } from 'src/__mocks__'; const mockRecommendationStrategy = () => ({ isRecommendationReached: jest.fn(), @@ -16,6 +18,7 @@ describe('RecommendationScanner', () => { let service: RecommendationScanner; let recommendationProvider; let recommendationStrategy; + let featureService: MockType; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -25,11 +28,16 @@ describe('RecommendationScanner', () => { provide: RecommendationProvider, useFactory: mockRecommendationProvider, }, + { + provide: FeatureService, + useFactory: mockFeatureService, + }, ], }).compile(); service = module.get(RecommendationScanner); recommendationProvider = module.get(RecommendationProvider); + featureService = module.get(FeatureService); recommendationStrategy = mockRecommendationStrategy(); recommendationProvider.getStrategy.mockReturnValue(recommendationStrategy); }); @@ -43,6 +51,16 @@ describe('RecommendationScanner', () => { })).toEqual({ name: 'name' }); }); + it('should return null when feature disabled', async () => { + featureService.isFeatureEnabled.mockResolvedValueOnce(false); + + recommendationStrategy.isRecommendationReached.mockResolvedValue({ isReached: true }); + + expect(await service.determineRecommendation('name', { + data: mockData, + })).toEqual(null); + }); + it('should return null when isRecommendationReached throw error', async () => { recommendationStrategy.isRecommendationReached.mockRejectedValueOnce(new Error()); diff --git a/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.ts b/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.ts index c09e524ac7..3c5ca9da6c 100644 --- a/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.ts +++ b/redisinsight/api/src/modules/database-recommendation/scanner/recommendations.scanner.ts @@ -1,13 +1,20 @@ import { Injectable } from '@nestjs/common'; import { RecommendationProvider } from 'src/modules/database-recommendation/scanner/recommendation.provider'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { KnownFeatures } from 'src/modules/feature/constants'; @Injectable() export class RecommendationScanner { constructor( private readonly recommendationProvider: RecommendationProvider, + private readonly featureService: FeatureService, ) {} async determineRecommendation(name: string, data: any) { + if (!await this.featureService.isFeatureEnabled(KnownFeatures.InsightsRecommendations)) { + return null; + } + const strategy = this.recommendationProvider.getStrategy(name); try { const recommendation = await strategy.isRecommendationReached(data); diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts new file mode 100644 index 0000000000..6ddc117a0f --- /dev/null +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -0,0 +1,28 @@ +export enum FeatureServerEvents { + FeaturesRecalculate = 'FeaturesRecalculate', + FeaturesRecalculated = 'FeaturesRecalculated', +} + +export enum FeatureEvents { + Features = 'features', +} + +export enum FeatureStorage { + Env = 'env', + Database = 'database', +} +export enum FeatureConfigConfigDestination { + Default = 'default', + Remote = 'remote', +} + +export enum KnownFeatures { + InsightsRecommendations = 'insightsRecommendations', +} + +export const knownFeatures = [ + { + name: KnownFeatures.InsightsRecommendations, + storage: FeatureStorage.Database, + }, +]; diff --git a/redisinsight/api/src/modules/feature/entities/feature.entity.ts b/redisinsight/api/src/modules/feature/entities/feature.entity.ts new file mode 100644 index 0000000000..a728c1d487 --- /dev/null +++ b/redisinsight/api/src/modules/feature/entities/feature.entity.ts @@ -0,0 +1,15 @@ +import { + Column, Entity, PrimaryColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; + +@Entity('features') +export class FeatureEntity { + @Expose() + @PrimaryColumn() + name: string; + + @Expose() + @Column() + flag: boolean; +} diff --git a/redisinsight/api/src/modules/feature/entities/features-config.entity.ts b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts new file mode 100644 index 0000000000..706113f9d1 --- /dev/null +++ b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts @@ -0,0 +1,25 @@ +import { + Column, Entity, PrimaryGeneratedColumn, UpdateDateColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; +import { DataAsJsonString } from 'src/common/decorators'; + +@Entity('features_config') +export class FeaturesConfigEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true, type: 'float' }) + @Expose() + controlNumber: number; + + @Column({ nullable: false }) + @Expose() + @DataAsJsonString() + data: string; + + @UpdateDateColumn() + @Expose() + updatedAt: Date; +} diff --git a/redisinsight/api/src/modules/feature/exceptions/index.ts b/redisinsight/api/src/modules/feature/exceptions/index.ts new file mode 100644 index 0000000000..879c12d27f --- /dev/null +++ b/redisinsight/api/src/modules/feature/exceptions/index.ts @@ -0,0 +1 @@ +export * from './unable-to-fetch-remote-config.exception'; diff --git a/redisinsight/api/src/modules/feature/exceptions/unable-to-fetch-remote-config.exception.ts b/redisinsight/api/src/modules/feature/exceptions/unable-to-fetch-remote-config.exception.ts new file mode 100644 index 0000000000..1f29b3ec96 --- /dev/null +++ b/redisinsight/api/src/modules/feature/exceptions/unable-to-fetch-remote-config.exception.ts @@ -0,0 +1,11 @@ +import { HttpException } from '@nestjs/common'; + +export class UnableToFetchRemoteConfigException extends HttpException { + constructor(response: string | Record = { + message: 'Unable to fetch remote config', + name: 'UnableToFetchRemoteConfigException', + statusCode: 500, + }, status = 500) { + super(response, status); + } +} diff --git a/redisinsight/api/src/modules/feature/feature.analytics.spec.ts b/redisinsight/api/src/modules/feature/feature.analytics.spec.ts new file mode 100644 index 0000000000..eaa976818e --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.analytics.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AppAnalyticsEvents, TelemetryEvents } from 'src/constants'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; +import { MockType } from 'src/__mocks__'; +import { UnableToFetchRemoteConfigException } from 'src/modules/feature/exceptions'; +import { ValidationError } from 'class-validator'; + +describe('FeatureAnalytics', () => { + let service: MockType; + let eventEmitter: EventEmitter2; + let sendEventSpy; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeatureAnalytics, + { + provide: EventEmitter2, + useFactory: () => ({ + emit: jest.fn(), + }), + }, + ], + }).compile(); + + service = await module.get(FeatureAnalytics); + eventEmitter = await module.get(EventEmitter2); + sendEventSpy = jest.spyOn( + service as any, + 'sendEvent', + ); + }); + + describe('sendFeatureFlagConfigUpdated', () => { + it('should emit FEATURE_FLAG_CONFIG_UPDATED telemetry event', async () => { + await service.sendFeatureFlagConfigUpdated({ + configVersion: 7.78, + oldVersion: 7.77, + type: 'default', + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagConfigUpdated, + eventData: { + configVersion: 7.78, + oldVersion: 7.77, + type: 'default', + }, + }); + }); + it('should not fail and do not send in case of any error', async () => { + sendEventSpy.mockImplementationOnce(() => { throw new Error('some kind of an error'); }); + + await service.sendFeatureFlagConfigUpdated({}); + + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('sendFeatureFlagRecalculated', () => { + it('should emit FEATURE_FLAG_RECALCULATED telemetry event', async () => { + await service.sendFeatureFlagRecalculated({ + configVersion: 7.78, + features: { + insightsRecommendations: { + flag: true, + }, + another_feature: { + flag: false, + }, + }, + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagRecalculated, + eventData: { + configVersion: 7.78, + features: { + insightsRecommendations: true, + another_feature: false, + }, + }, + }); + }); + it('should not fail and do not send in case of an error', async () => { + sendEventSpy.mockImplementationOnce(() => { throw new Error(); }); + + await service.sendFeatureFlagRecalculated({}); + + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('sendFeatureFlagConfigUpdateError', () => { + it('should emit telemetry event (common Error)', async () => { + await service.sendFeatureFlagConfigUpdateError({ + configVersion: 7.78, + type: 'default', + error: new Error('some sensitive information'), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagConfigUpdateError, + eventData: { + configVersion: 7.78, + type: 'default', + reason: 'Error', + }, + }); + }); + it('should emit telemetry event (UnableToFetchRemoteConfigException)', async () => { + await service.sendFeatureFlagConfigUpdateError({ + configVersion: 7.78, + error: new UnableToFetchRemoteConfigException('some PII'), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagConfigUpdateError, + eventData: { + configVersion: 7.78, + reason: 'UnableToFetchRemoteConfigException', + }, + }); + }); + it('should emit telemetry event (ValidationError)', async () => { + await service.sendFeatureFlagConfigUpdateError({ + configVersion: 7.78, + type: 'remote', + error: new ValidationError(), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagConfigUpdateError, + eventData: { + configVersion: 7.78, + type: 'remote', + reason: 'ValidationError', + }, + }); + }); + it('should emit telemetry event ([ValidationError] only first exception)', async () => { + await service.sendFeatureFlagConfigUpdateError({ + configVersion: 7.78, + type: 'remote', + error: [new ValidationError(), new Error('2nd error which will be ignored')], + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagConfigUpdateError, + eventData: { + configVersion: 7.78, + type: 'remote', + reason: 'ValidationError', + }, + }); + }); + it('should not fail and not send in case of an error', async () => { + sendEventSpy.mockImplementationOnce(() => { throw new Error('some error'); }); + + await service.sendFeatureFlagConfigUpdateError({}); + + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); + + describe('sendFeatureFlagInvalidRemoteConfig', () => { + it('should emit telemetry event (common Error)', async () => { + await service.sendFeatureFlagInvalidRemoteConfig({ + configVersion: 7.78, + type: 'default', + error: new Error('some sensitive information'), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagInvalidRemoteConfig, + eventData: { + configVersion: 7.78, + type: 'default', + reason: 'Error', + }, + }); + }); + it('should emit telemetry event (UnableToFetchRemoteConfigException)', async () => { + await service.sendFeatureFlagInvalidRemoteConfig({ + configVersion: 7.78, + error: new UnableToFetchRemoteConfigException('some PII'), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagInvalidRemoteConfig, + eventData: { + configVersion: 7.78, + reason: 'UnableToFetchRemoteConfigException', + }, + }); + }); + it('should emit telemetry event (ValidationError)', async () => { + await service.sendFeatureFlagInvalidRemoteConfig({ + configVersion: 7.78, + type: 'remote', + error: new ValidationError(), + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagInvalidRemoteConfig, + eventData: { + configVersion: 7.78, + type: 'remote', + reason: 'ValidationError', + }, + }); + }); + it('should emit telemetry event ([ValidationError] only first exception)', async () => { + await service.sendFeatureFlagInvalidRemoteConfig({ + configVersion: 7.78, + type: 'remote', + error: [new ValidationError(), new Error('2nd error which will be ignored')], + }); + + expect(eventEmitter.emit).toHaveBeenCalledWith(AppAnalyticsEvents.Track, { + event: TelemetryEvents.FeatureFlagInvalidRemoteConfig, + eventData: { + configVersion: 7.78, + type: 'remote', + reason: 'ValidationError', + }, + }); + }); + it('should not fail and not send in case of an error', async () => { + sendEventSpy.mockImplementationOnce(() => { throw new Error('some error'); }); + + await service.sendFeatureFlagInvalidRemoteConfig({}); + + expect(eventEmitter.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/feature.analytics.ts b/redisinsight/api/src/modules/feature/feature.analytics.ts new file mode 100644 index 0000000000..8e740c6d86 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.analytics.ts @@ -0,0 +1,101 @@ +import { forEach, isArray } from 'lodash'; +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; + +@Injectable() +export class FeatureAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + static getReason(error: Error | Error[]): string { + let reason = error; + + if (isArray(error)) { + [reason] = error; + } + + return reason?.constructor?.name || 'UncaughtError'; + } + + sendFeatureFlagConfigUpdated(data: { + configVersion: number, + oldVersion: number, + type?: string, + }): void { + try { + this.sendEvent( + TelemetryEvents.FeatureFlagConfigUpdated, + { + configVersion: data.configVersion, + oldVersion: data.oldVersion, + type: data.type, + }, + ); + } catch (e) { + // ignore error + } + } + + sendFeatureFlagConfigUpdateError(data: { + error: Error | Error[], + configVersion?: number, + type?: string, + }): void { + try { + this.sendEvent( + TelemetryEvents.FeatureFlagConfigUpdateError, + { + configVersion: data.configVersion, + type: data.type, + reason: FeatureAnalytics.getReason(data.error), + }, + ); + } catch (e) { + // ignore error + } + } + + sendFeatureFlagInvalidRemoteConfig(data: { + error: Error | Error[], + configVersion?: number, + type?: string, + }): void { + try { + this.sendEvent( + TelemetryEvents.FeatureFlagInvalidRemoteConfig, + { + configVersion: data.configVersion, + type: data.type, + reason: FeatureAnalytics.getReason(data.error), + }, + ); + } catch (e) { + // ignore error + } + } + + sendFeatureFlagRecalculated(data: { + configVersion: number, + features: Record + }): void { + try { + const features = {}; + forEach(data?.features || {}, (value, key) => { + features[key] = value?.flag; + }); + + this.sendEvent( + TelemetryEvents.FeatureFlagRecalculated, + { + configVersion: data.configVersion, + features, + }, + ); + } catch (e) { + // ignore error + } + } +} diff --git a/redisinsight/api/src/modules/feature/feature.controller.ts b/redisinsight/api/src/modules/feature/feature.controller.ts new file mode 100644 index 0000000000..f9a662a629 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Get, HttpCode, Post, + UsePipes, + ValidationPipe +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; + +@ApiTags('Info') +@Controller('features') +@UsePipes(new ValidationPipe({ transform: true })) +export class FeatureController { + constructor( + private featureService: FeatureService, + private featuresConfigService: FeaturesConfigService, + ) {} + + @Get('') + @ApiEndpoint({ + description: 'Get list of features', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Get list of features', + }, + ], + }) + async list(): Promise { + return this.featureService.list(); + } + + @Post('/sync') + @HttpCode(200) + async sync(): Promise { + return this.featuresConfigService.sync(); + } +} diff --git a/redisinsight/api/src/modules/feature/feature.gateway.ts b/redisinsight/api/src/modules/feature/feature.gateway.ts new file mode 100644 index 0000000000..28af1cf5a4 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.gateway.ts @@ -0,0 +1,20 @@ +import { Server } from 'socket.io'; +import { + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import config from 'src/utils/config'; +import { OnEvent } from '@nestjs/event-emitter'; +import { FeatureEvents, FeatureServerEvents } from 'src/modules/feature/constants'; + +const SOCKETS_CONFIG = config.get('sockets'); + +@WebSocketGateway({ cors: SOCKETS_CONFIG.cors, serveClient: SOCKETS_CONFIG.serveClient }) +export class FeatureGateway { + @WebSocketServer() wss: Server; + + @OnEvent(FeatureServerEvents.FeaturesRecalculated) + feature(data: any) { + this.wss.of('/').emit(FeatureEvents.Features, data); + } +} diff --git a/redisinsight/api/src/modules/feature/feature.module.ts b/redisinsight/api/src/modules/feature/feature.module.ts new file mode 100644 index 0000000000..4778aae255 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.module.ts @@ -0,0 +1,47 @@ +import { Module, Type } from '@nestjs/common'; +import { FeatureController } from 'src/modules/feature/feature.controller'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { NotificationModule } from 'src/modules/notification/notification.module'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { FeatureRepository } from 'src/modules/feature/repositories/feature.repository'; +import { LocalFeatureRepository } from 'src/modules/feature/repositories/local.feature.repository'; +import { FeatureFlagProvider } from 'src/modules/feature/providers/feature-flag/feature-flag.provider'; +import { FeatureGateway } from 'src/modules/feature/feature.gateway'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; + +@Module({}) +export class FeatureModule { + static register( + featureRepository: Type = LocalFeatureRepository, + featuresConfigRepository: Type = LocalFeaturesConfigRepository, + ) { + return { + module: FeatureModule, + controllers: [FeatureController], + providers: [ + FeatureService, + FeaturesConfigService, + FeatureFlagProvider, + FeatureGateway, + FeatureAnalytics, + { + provide: FeatureRepository, + useClass: featureRepository, + }, + { + provide: FeaturesConfigRepository, + useClass: featuresConfigRepository, + }, + ], + exports: [ + FeatureService, + FeaturesConfigService, + ], + imports: [ + NotificationModule, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/feature/feature.service.spec.ts b/redisinsight/api/src/modules/feature/feature.service.spec.ts new file mode 100644 index 0000000000..2abbbb598f --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.service.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import axios from 'axios'; +import { + mockFeature, mockFeatureAnalytics, mockFeatureFlagProvider, mockFeatureRepository, + mockFeaturesConfig, + mockFeaturesConfigJson, + mockFeaturesConfigRepository, + MockType, mockUnknownFeature, +} from 'src/__mocks__'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { KnownFeatures } from 'src/modules/feature/constants'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; +import { FeatureService } from 'src/modules/feature/feature.service'; +import { FeatureRepository } from 'src/modules/feature/repositories/feature.repository'; +import { FeatureFlagProvider } from 'src/modules/feature/providers/feature-flag/feature-flag.provider'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('FeatureService', () => { + let service: FeatureService; + let repository: MockType; + let configsRepository: MockType; + let analytics: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeatureService, + { + provide: EventEmitter2, + useFactory: () => ({ + emit: jest.fn(), + }), + }, + { + provide: FeaturesConfigRepository, + useFactory: mockFeaturesConfigRepository, + }, + { + provide: FeatureRepository, + useFactory: mockFeatureRepository, + }, + { + provide: FeatureAnalytics, + useFactory: mockFeatureAnalytics, + }, + { + provide: FeatureFlagProvider, + useFactory: mockFeatureFlagProvider, + }, + ], + }).compile(); + + service = module.get(FeatureService); + repository = module.get(FeatureRepository); + configsRepository = module.get(FeaturesConfigRepository); + analytics = module.get(FeatureAnalytics); + + mockedAxios.get.mockResolvedValue({ data: mockFeaturesConfigJson }); + }); + + describe('isFeatureEnabled', () => { + it('should return true when in db: true', async () => { + expect(await service.isFeatureEnabled(KnownFeatures.InsightsRecommendations)).toEqual(true); + }); + it('should return false when in db: false', async () => { + repository.get.mockResolvedValue({ flag: false }); + expect(await service.isFeatureEnabled(KnownFeatures.InsightsRecommendations)).toEqual(false); + }); + it('should return false in case of an error', async () => { + repository.get.mockRejectedValueOnce(new Error('Unable to fetch flag from db')); + expect(await service.isFeatureEnabled(KnownFeatures.InsightsRecommendations)).toEqual(false); + }); + }); + + describe('list', () => { + it('should return list of features flags', async () => { + expect(await service.list()) + .toEqual({ + features: { + [KnownFeatures.InsightsRecommendations]: { + flag: true, + }, + }, + }); + }); + }); + + describe('recalculateFeatureFlags', () => { + it('should recalculate flags (1 update an 1 delete)', async () => { + repository.list.mockResolvedValueOnce([mockFeature, mockUnknownFeature]); + repository.list.mockResolvedValueOnce([mockFeature]); + configsRepository.getOrCreate.mockResolvedValueOnce(mockFeaturesConfig); + + await service.recalculateFeatureFlags(); + + expect(repository.delete) + .toHaveBeenCalledWith(mockUnknownFeature); + expect(repository.upsert) + .toHaveBeenCalledWith({ + name: KnownFeatures.InsightsRecommendations, + flag: mockFeaturesConfig.data.features.get(KnownFeatures.InsightsRecommendations).flag, + }); + expect(analytics.sendFeatureFlagRecalculated).toHaveBeenCalledWith({ + configVersion: mockFeaturesConfig.data.version, + features: { + [KnownFeatures.InsightsRecommendations]: { + flag: true, + }, + }, + }); + }); + it('should not fail in case of an error', async () => { + repository.list.mockRejectedValueOnce(new Error()); + + await service.recalculateFeatureFlags(); + + expect(repository.delete).not.toHaveBeenCalled(); + expect(repository.upsert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/feature.service.ts b/redisinsight/api/src/modules/feature/feature.service.ts new file mode 100644 index 0000000000..7e1125ae08 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -0,0 +1,111 @@ +import { find } from 'lodash'; +import { Injectable, Logger } from '@nestjs/common'; +import { FeatureRepository } from 'src/modules/feature/repositories/feature.repository'; +import { FeatureServerEvents, FeatureStorage, knownFeatures } from 'src/modules/feature/constants'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { FeatureFlagProvider } from 'src/modules/feature/providers/feature-flag/feature-flag.provider'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; + +@Injectable() +export class FeatureService { + private logger = new Logger('FeaturesConfigService'); + + constructor( + private readonly repository: FeatureRepository, + private readonly featuresConfigRepository: FeaturesConfigRepository, + private readonly featureFlagProvider: FeatureFlagProvider, + private readonly eventEmitter: EventEmitter2, + private readonly analytics: FeatureAnalytics, + ) {} + + /** + * Check if feature enabled + * @param name + */ + async isFeatureEnabled(name: string): Promise { + try { + // todo: add non-database features if needed + const model = await this.repository.get(name); + + return model?.flag === true; + } catch (e) { + return false; + } + } + + /** + * Returns list of features flags + */ + async list() { + this.logger.log('Getting features list'); + + const features = {}; + + const featuresFromDatabase = await this.repository.list(); + + knownFeatures.forEach((feature) => { + // todo: implement various storage strategies support with next features + if (feature.storage === FeatureStorage.Database) { + const dbFeature = find(featuresFromDatabase, { name: feature.name }); + if (dbFeature) { + features[feature.name] = { flag: dbFeature.flag }; + } + } + }); + + try { + this.analytics.sendFeatureFlagRecalculated({ + configVersion: (await this.featuresConfigRepository.getOrCreate())?.data?.version, + features, + }); + } catch (e) { + // ignore telemetry error + } + return { features }; + } + + // todo: add api doc + models + /** + * Recalculate flags for database features based on controlGroup and new conditions + */ + @OnEvent(FeatureServerEvents.FeaturesRecalculate) + async recalculateFeatureFlags() { + this.logger.log('Recalculating features flags'); + + try { + const actions = { + toUpsert: [], + toDelete: [], + }; + + const featuresFromDatabase = await this.repository.list(); + const featuresConfig = await this.featuresConfigRepository.getOrCreate(); + + this.logger.debug('Recalculating features flags for new config', featuresConfig); + + await Promise.all(Array.from(featuresConfig?.data?.features || new Map(), async ([name, feature]) => { + actions.toUpsert.push({ + name, + flag: await this.featureFlagProvider.calculate(name, feature), + }); + })); + + // calculate to delete features + actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.data?.features?.has?.(feature.name)); + + // delete features + await Promise.all(actions.toDelete.map((feature) => this.repository.delete(feature))); + // upsert modified features + await Promise.all(actions.toUpsert.map((feature) => this.repository.upsert(feature))); + + this.logger.log( + `Features flags recalculated. Updated: ${actions.toUpsert.length} deleted: ${actions.toDelete.length}`, + ); + + this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculated, await this.list()); + } catch (e) { + this.logger.error('Unable to recalculate features flags', e); + } + } +} diff --git a/redisinsight/api/src/modules/feature/features-config.service.spec.ts b/redisinsight/api/src/modules/feature/features-config.service.spec.ts new file mode 100644 index 0000000000..77d7818b99 --- /dev/null +++ b/redisinsight/api/src/modules/feature/features-config.service.spec.ts @@ -0,0 +1,170 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import axios from 'axios'; +import { + mockControlGroup, + mockControlNumber, mockFeatureAnalytics, + mockFeaturesConfig, + mockFeaturesConfigJson, + mockFeaturesConfigRepository, + MockType, +} from 'src/__mocks__'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { plainToClass } from 'class-transformer'; +import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; +import { FeatureConfigConfigDestination, FeatureServerEvents, KnownFeatures } from 'src/modules/feature/constants'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; +import { UnableToFetchRemoteConfigException } from 'src/modules/feature/exceptions'; +import * as defaultConfig from '../../../config/features-config.json'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('FeaturesConfigService', () => { + let service: FeaturesConfigService; + let repository: MockType; + let analytics: MockType; + let eventEmitter: EventEmitter2; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeaturesConfigService, + { + provide: EventEmitter2, + useFactory: () => ({ + emit: jest.fn(), + }), + }, + { + provide: FeaturesConfigRepository, + useFactory: mockFeaturesConfigRepository, + }, + { + provide: FeatureAnalytics, + useFactory: mockFeatureAnalytics, + }, + ], + }).compile(); + + service = module.get(FeaturesConfigService); + repository = module.get(FeaturesConfigRepository); + analytics = module.get(FeatureAnalytics); + eventEmitter = module.get(EventEmitter2); + + mockedAxios.get.mockResolvedValue({ data: mockFeaturesConfigJson }); + }); + + describe('onApplicationBootstrap', () => { + it('should sync on bootstrap', async () => { + const spy = jest.spyOn(service, 'sync'); + await service['onApplicationBootstrap'](); + + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getNewConfig', () => { + it('should return remote config', async () => { + const result = await service['getNewConfig'](); + + expect(result).toEqual({ data: mockFeaturesConfigJson, type: FeatureConfigConfigDestination.Remote }); + expect(analytics.sendFeatureFlagInvalidRemoteConfig).not.toHaveBeenCalled(); + }); + it('should return default config when unable to fetch remote config', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('404 not found')); + + const result = await service['getNewConfig'](); + + expect(result).toEqual({ data: defaultConfig, type: FeatureConfigConfigDestination.Default }); + expect(analytics.sendFeatureFlagInvalidRemoteConfig).toHaveBeenCalledWith({ + configVersion: undefined, // no config version since unable to fetch + error: new UnableToFetchRemoteConfigException(), + }); + }); + it('should return default config when invalid remote config fetched', async () => { + const validateSpy = jest.spyOn(service['validator'], 'validateOrReject'); + const validationError = new Error('ValidationError'); + validateSpy.mockRejectedValueOnce([validationError]); + mockedAxios.get.mockResolvedValue({ + data: { + ...mockFeaturesConfigJson, + features: { + [KnownFeatures.InsightsRecommendations]: { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], + flag: 'not boolean flag', + }, + }, + }, + }); + + const result = await service['getNewConfig'](); + + expect(result).toEqual({ data: defaultConfig, type: FeatureConfigConfigDestination.Default }); + expect(analytics.sendFeatureFlagInvalidRemoteConfig).toHaveBeenCalledWith({ + configVersion: mockFeaturesConfigJson.version, // no config version since unable to fetch + error: [validationError], + }); + }); + it('should return default config when remote config version less then default', async () => { + mockedAxios.get.mockResolvedValue({ + data: { + ...mockFeaturesConfigJson, + version: defaultConfig.version - 0.1, + }, + }); + + const result = await service['getNewConfig'](); + + expect(result).toEqual({ data: defaultConfig, type: FeatureConfigConfigDestination.Default }); + expect(analytics.sendFeatureFlagInvalidRemoteConfig).not.toHaveBeenCalled(); + }); + }); + + describe('sync', () => { + it('should update to the latest remote config', async () => { + repository.getOrCreate.mockResolvedValue({ + ...mockFeaturesConfig, + data: plainToClass(FeaturesConfigData, defaultConfig), + }); + + await service['sync'](); + + expect(repository.update).toHaveBeenCalledWith(mockFeaturesConfigJson); + expect(eventEmitter.emit).toHaveBeenCalledWith(FeatureServerEvents.FeaturesRecalculate); + expect(analytics.sendFeatureFlagConfigUpdated).toHaveBeenCalledWith({ + oldVersion: defaultConfig.version, + configVersion: mockFeaturesConfig.data.version, + type: FeatureConfigConfigDestination.Remote, + }); + }); + it('should not fail and not emit recalculate event in case of an error', async () => { + repository.getOrCreate.mockResolvedValue({ + ...mockFeaturesConfig, + data: plainToClass(FeaturesConfigData, defaultConfig), + }); + repository.update.mockRejectedValueOnce(new Error('update error')); + + await service['sync'](); + + expect(repository.update).toHaveBeenCalledWith(mockFeaturesConfigJson); + expect(eventEmitter.emit).not.toHaveBeenCalledWith(FeatureServerEvents.FeaturesRecalculate); + expect(analytics.sendFeatureFlagConfigUpdated).not.toHaveBeenCalled(); + }); + }); + + describe('getControlInfo', () => { + it('should get controlNumber and controlGroup', async () => { + repository.getOrCreate.mockResolvedValue(mockFeaturesConfig); + + const result = await service['getControlInfo'](); + + expect(result).toEqual({ + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/features-config.service.ts b/redisinsight/api/src/modules/feature/features-config.service.ts new file mode 100644 index 0000000000..e4a0338e89 --- /dev/null +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -0,0 +1,133 @@ +import axios from 'axios'; +import { + Injectable, Logger, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import config from 'src/utils/config'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { FeatureConfigConfigDestination, FeatureServerEvents } from 'src/modules/feature/constants'; +import { Validator } from 'class-validator'; +import { plainToClass } from 'class-transformer'; +import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; +import { FeatureAnalytics } from 'src/modules/feature/feature.analytics'; +import { UnableToFetchRemoteConfigException } from 'src/modules/feature/exceptions'; +import * as defaultConfig from '../../../config/features-config.json'; + +const FEATURES_CONFIG = config.get('features_config'); + +@Injectable() +export class FeaturesConfigService { + private logger = new Logger('FeaturesConfigService'); + + private validator = new Validator(); + + constructor( + private readonly repository: FeaturesConfigRepository, + private readonly eventEmitter: EventEmitter2, + private readonly analytics: FeatureAnalytics, + ) {} + + async onApplicationBootstrap() { + this.sync().catch(); + if (FEATURES_CONFIG.syncInterval > 0) { + setInterval(this.sync.bind(this), FEATURES_CONFIG.syncInterval); + } + } + + /** + * Fetch remote new config from remote server + * @private + */ + private async fetchRemoteConfig(): Promise { + try { + this.logger.log('Fetching remote config...'); + + const { data } = await axios.get(FEATURES_CONFIG.url); + + return data; + } catch (error) { + this.logger.error('Unable to fetch remote config', error); + throw new UnableToFetchRemoteConfigException(); + } + } + + private async getNewConfig(): Promise<{ data: any, type: FeatureConfigConfigDestination }> { + let remoteConfig: any; + let newConfig: any = { + data: defaultConfig, + type: FeatureConfigConfigDestination.Default, + }; + + try { + this.logger.log('Fetching remote config...'); + + remoteConfig = await this.fetchRemoteConfig(); + + // we should use default config in case when remote is invalid + await this.validator.validateOrReject(plainToClass(FeaturesConfigData, remoteConfig)); + + if (remoteConfig?.version > defaultConfig?.version) { + newConfig = { + data: remoteConfig, + type: FeatureConfigConfigDestination.Remote, + }; + } + } catch (error) { + this.analytics.sendFeatureFlagInvalidRemoteConfig({ + configVersion: remoteConfig?.version, + error, + }); + + this.logger.error('Something wrong with remote config', error); + } + + return newConfig; + } + + /** + * Get latest config from remote and save it in the local database + */ + public async sync(): Promise { + let newConfig; + + try { + this.logger.log('Trying to sync features config...'); + + const currentConfig = await this.repository.getOrCreate(); + newConfig = await this.getNewConfig(); + + if (newConfig?.data?.version > currentConfig?.data?.version) { + await this.repository.update(newConfig.data); + this.analytics.sendFeatureFlagConfigUpdated({ + oldVersion: currentConfig?.data?.version, + configVersion: newConfig.data.version, + type: newConfig.type, + }); + } + + this.logger.log('Successfully updated stored remote config'); + this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculate); + } catch (error) { + this.analytics.sendFeatureFlagConfigUpdateError({ + configVersion: newConfig?.version, + error, + }); + + this.logger.error('Unable to update features config', error); + } + } + + /** + * Get control group field + */ + public async getControlInfo(): Promise<{ controlNumber: number, controlGroup: string }> { + this.logger.debug('Trying to get controlGroup field'); + + const model = await (this.repository.getOrCreate()); + + return { + controlNumber: model.controlNumber, + controlGroup: parseInt(model.controlNumber.toString(), 10).toFixed(0), + }; + } +} diff --git a/redisinsight/api/src/modules/feature/model/feature.ts b/redisinsight/api/src/modules/feature/model/feature.ts new file mode 100644 index 0000000000..fa475968c9 --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/feature.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class Feature { + @Expose() + name: string; + + @Expose() + flag: boolean; +} diff --git a/redisinsight/api/src/modules/feature/model/features-config.spec.ts b/redisinsight/api/src/modules/feature/model/features-config.spec.ts new file mode 100644 index 0000000000..33358a08fa --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/features-config.spec.ts @@ -0,0 +1,65 @@ +import { + mockFeaturesConfig, mockFeaturesConfigComplex, mockFeaturesConfigEntity, mockFeaturesConfigEntityComplex, + mockFeaturesConfigJson, mockFeaturesConfigJsonComplex, +} from 'src/__mocks__'; +import { classToPlain, plainToClass } from 'class-transformer'; +import { FeaturesConfig } from 'src/modules/feature/model/features-config'; +import { classToClass } from 'src/utils'; +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; + +const testCases = [ + { + plain: { + ...mockFeaturesConfig, + data: { ...mockFeaturesConfigJson }, + }, + model: mockFeaturesConfig, + entity: Object.assign(new FeaturesConfigEntity(), { ...mockFeaturesConfigEntity, id: undefined }), + }, + { + plain: { + ...mockFeaturesConfigComplex, + data: { ...mockFeaturesConfigJsonComplex }, + }, + model: mockFeaturesConfigComplex, + entity: Object.assign(new FeaturesConfigEntity(), { ...mockFeaturesConfigEntityComplex, id: undefined }), + }, + { + plain: {}, + model: {}, + entity: {}, + }, + { + plain: null, + model: null, + entity: null, + }, + { + plain: undefined, + model: undefined, + entity: undefined, + }, + { + plain: 'incorrectdata', + model: 'incorrectdata', + entity: 'incorrectdata', + }, +]; + +describe('FeaturesConfig', () => { + describe('transform', () => { + testCases.forEach((tc) => { + it(`input ${JSON.stringify(tc.plain)}`, async () => { + const modelFromPlain = plainToClass(FeaturesConfig, tc.plain); + const plainFromModel = classToPlain(modelFromPlain); + const entityFromModel = classToClass(FeaturesConfigEntity, modelFromPlain); + const modelFromEntity = classToClass(FeaturesConfig, entityFromModel); + + expect(tc.model).toEqual(modelFromPlain); + expect(tc.plain).toEqual(plainFromModel); + expect(tc.entity).toEqual(entityFromModel); + expect(tc.model).toEqual(modelFromEntity); + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/model/features-config.ts b/redisinsight/api/src/modules/feature/model/features-config.ts new file mode 100644 index 0000000000..5e2c673879 --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/features-config.ts @@ -0,0 +1,88 @@ +import { Expose, Transform, Type } from 'class-transformer'; +import { + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, ValidateNested, +} from 'class-validator'; +import { IsMultiNumber, ObjectAsMap } from 'src/common/decorators'; +import { featureConfigFilterTransformer } from 'src/modules/feature/transformers'; + +export enum FeatureConfigFilterCondition { + Eq = 'eq', + Neq = 'neq', + Gt = 'gt', + Gte = 'gte', + Lt = 'lt', + Lte = 'lte', +} + +export type FeatureConfigFilterType = FeatureConfigFilter | FeatureConfigFilterOr | FeatureConfigFilterAnd; + +export class FeatureConfigFilter { + @Expose() + @IsString() + @IsNotEmpty() + name: string; + + @Expose() + @IsEnum(FeatureConfigFilterCondition) + cond: FeatureConfigFilterCondition; + + @Expose() + value: any; +} + +export class FeatureConfigFilterOr { + @Expose() + @IsArray() + @Transform(featureConfigFilterTransformer) + @ValidateNested({ each: true }) + or: FeatureConfigFilterType[]; +} + +export class FeatureConfigFilterAnd { + @Expose() + @IsArray() + @Transform(featureConfigFilterTransformer) + @ValidateNested({ each: true }) + and: FeatureConfigFilterType[]; +} + +export class FeatureConfig { + @Expose() + @IsNotEmpty() + @IsBoolean() + flag: boolean; + + @Expose() + @IsArray({ each: true }) + @IsMultiNumber() + perc: number[][]; + + @Expose() + @IsArray() + @Transform(featureConfigFilterTransformer) + @ValidateNested({ each: true }) + filters: FeatureConfigFilterType[]; +} + +export class FeaturesConfigData { + @Expose() + @IsNotEmpty() + @IsNumber() + version: number; + + @Expose() + @ObjectAsMap(FeatureConfig) + @ValidateNested({ each: true }) + features: Map; +} + +export class FeaturesConfig { + @Expose() + @IsNumber() + controlNumber: number; + + @Expose() + @Type(() => FeaturesConfigData) + @ValidateNested() + data: FeaturesConfigData; +} diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts new file mode 100644 index 0000000000..557ba78e63 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockFeaturesConfig, + mockFeaturesConfigService, mockInsightsRecommendationsFlagStrategy, mockSettingsService, +} from 'src/__mocks__'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { FeatureFlagProvider } from 'src/modules/feature/providers/feature-flag/feature-flag.provider'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { KnownFeatures } from 'src/modules/feature/constants'; +import { + InsightsRecommendationsFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; +import { DefaultFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/default.flag.strategy'; + +describe('FeatureFlagProvider', () => { + let service: FeatureFlagProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeatureFlagProvider, + { + provide: FeaturesConfigService, + useFactory: mockFeaturesConfigService, + }, + { + provide: SettingsService, + useFactory: mockSettingsService, + }, + ], + }).compile(); + + service = module.get(FeatureFlagProvider); + }); + + describe('getStrategy', () => { + it('should return insights strategy', async () => { + expect(await service.getStrategy(KnownFeatures.InsightsRecommendations)) + .toBeInstanceOf(InsightsRecommendationsFlagStrategy); + }); + it('should return default strategy when directly called', async () => { + expect(await service.getStrategy('default')) + .toBeInstanceOf(DefaultFlagStrategy); + }); + it('should return default strategy when when no strategy found', async () => { + expect(await service.getStrategy('some not existing strategy')) + .toBeInstanceOf(DefaultFlagStrategy); + }); + }); + + describe('calculate', () => { + it('should calculate ', async () => { + jest.spyOn(service, 'getStrategy') + .mockReturnValue(mockInsightsRecommendationsFlagStrategy as unknown as InsightsRecommendationsFlagStrategy); + + expect(await service.calculate( + KnownFeatures.InsightsRecommendations, + mockFeaturesConfig[KnownFeatures.InsightsRecommendations], + )).toEqual(true); + expect(mockInsightsRecommendationsFlagStrategy.calculate).toHaveBeenCalledWith( + mockFeaturesConfig[KnownFeatures.InsightsRecommendations], + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts new file mode 100644 index 0000000000..e4a3091836 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; +import { + InsightsRecommendationsFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; +import { DefaultFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/default.flag.strategy'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { KnownFeatures } from 'src/modules/feature/constants'; + +@Injectable() +export class FeatureFlagProvider { + private strategies: Map = new Map(); + + constructor( + private readonly featuresConfigService: FeaturesConfigService, + private readonly settingsService: SettingsService, + ) { + this.strategies.set('default', new DefaultFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); + this.strategies.set(KnownFeatures.InsightsRecommendations, new InsightsRecommendationsFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); + } + + getStrategy(name: string): FeatureFlagStrategy { + return this.strategies.get(name) || this.getStrategy('default'); + } + + calculate(name: string, featureConditions: any): Promise { + const strategy = this.getStrategy(name); + + return strategy.calculate(featureConditions); + } +} diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/default.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/default.flag.strategy.ts new file mode 100644 index 0000000000..fd28dce315 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/default.flag.strategy.ts @@ -0,0 +1,7 @@ +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; + +export class DefaultFlagStrategy extends FeatureFlagStrategy { + async calculate(): Promise { + return false; + } +} diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts new file mode 100644 index 0000000000..5eb7d3c35c --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts @@ -0,0 +1,419 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockAppSettings, + mockFeaturesConfig, + mockFeaturesConfigDataComplex, mockFeaturesConfigJson, + mockFeaturesConfigService, + mockServerState, + mockSettingsService, + MockType, +} from 'src/__mocks__'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; +import { + InsightsRecommendationsFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; +import { + FeatureConfigFilter, + FeatureConfigFilterAnd, + FeatureConfigFilterCondition, +} from 'src/modules/feature/model/features-config'; +import { KnownFeatures } from 'src/modules/feature/constants'; +import { DefaultFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/default.flag.strategy'; + +describe('FeatureFlagStrategy', () => { + let service: FeatureFlagStrategy; + let settingsService: MockType; + let featuresConfigService: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: SettingsService, + useFactory: mockSettingsService, + }, + { + provide: FeaturesConfigService, + useFactory: mockFeaturesConfigService, + }, + ], + }).compile(); + + settingsService = module.get(SettingsService); + featuresConfigService = module.get(FeaturesConfigService); + service = new InsightsRecommendationsFlagStrategy( + featuresConfigService as unknown as FeaturesConfigService, + settingsService as unknown as SettingsService, + ); + + settingsService.getAppSettings.mockResolvedValue(mockAppSettings); + }); + + describe('isInTargetRange', () => { + const testCases = [ + [[], false], // disable for all + [[[0, 100]], true], + [[[0, 50]], true], + [[[0, 1], [2, 3], [5, 10]], true], + [[[0, 1]], false], + [[[5, -600]], false], + [[[100, -600]], false], + [[[0, 0]], false], + [[[0, mockFeaturesConfig.controlNumber]], false], + [[[0, mockFeaturesConfig.controlNumber + 0.01]], true], + ]; + + testCases.forEach((tc) => { + it(`should return ${tc[1]} for range: [${tc[0]}]`, async () => { + expect(await service['isInTargetRange'](tc[0] as number[][])).toEqual(tc[1]); + }); + }); + + it('should return false in case of any error', async () => { + featuresConfigService.getControlInfo.mockRejectedValueOnce(new Error('unable to get control info')); + + expect(await service['isInTargetRange']([[0, 100]])).toEqual(false); + }); + }); + + describe('getServerState', () => { + it('should return server state', async () => { + expect(await service['getServerState']()).toEqual(mockServerState); + }); + it('should return nulls in case of any error', async () => { + settingsService.getAppSettings.mockRejectedValueOnce(new Error('unable to get app settings')); + + expect(await service['getServerState']()).toEqual({ + ...mockServerState, + agreements: null, + settings: null, + }); + }); + }); + + describe('filter', () => { + it('should return when no filters defined', async () => { + expect(await service['filter']([])).toEqual(true); + }); + it('should return true for single filter by agreements (eq)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: FeatureConfigFilterCondition.Eq, + }), + ])).toEqual(true); + }); + it('should return false for single filter by agreements (eq)', async () => { + settingsService.getAppSettings.mockResolvedValue({ + ...mockAppSettings, + agreements: { + ...mockAppSettings.agreements, + analytics: false, + }, + }); + + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: FeatureConfigFilterCondition.Eq, + }), + ])).toEqual(false); + }); + it('should return false for single filter by agreements (neq)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: FeatureConfigFilterCondition.Neq, + }), + ])).toEqual(false); + }); + it('should return false for unsupported condition (unsupported)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: 'unsupported' as FeatureConfigFilterCondition, + }), + ])).toEqual(false); + }); + it('should return false numeric settings (eq)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: FeatureConfigFilterCondition.Eq, + }), + ])).toEqual(true); + }); + it('should return false for numeric settings (gt)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: FeatureConfigFilterCondition.Gt, + }), + ])).toEqual(false); + }); + it('should return true for numeric settings (gt)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold - 1, + cond: FeatureConfigFilterCondition.Gt, + }), + ])).toEqual(true); + }); + it('should return true numeric settings (gte)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: FeatureConfigFilterCondition.Gte, + }), + ])).toEqual(true); + }); + it('should return false for numeric settings (lt)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: FeatureConfigFilterCondition.Lt, + }), + ])).toEqual(false); + }); + it('should return true for numeric settings (lt)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold + 1, + cond: FeatureConfigFilterCondition.Lt, + }), + ])).toEqual(true); + }); + it('should return true numeric settings (lte)', async () => { + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'settings.scanThreshold', + value: mockAppSettings.scanThreshold, + cond: FeatureConfigFilterCondition.Lte, + }), + ])).toEqual(true); + }); + + it('should return false in case of an error', async () => { + const spy = jest.spyOn(service as any, 'getServerState'); + spy.mockRejectedValueOnce(new Error('unable to get state')); + + expect(await service['filter']([ + Object.assign(new FeatureConfigFilter(), { + name: 'agreements.analytics', + value: true, + cond: FeatureConfigFilterCondition.Eq, + }), + ])).toEqual(false); + }); + }); + + describe('filter (complex)', () => { + it('should return true since 2nd "or" condition is true', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + agreements: { analytics: true }, + }); + + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(true); + }); + it('should return false since 2nd "or" condition is false due to "and" inside is false', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + agreements: { analytics: true }, + scanThreshold: mockAppSettings.scanThreshold + 1, + batchSize: mockAppSettings.batchSize + 1, + }); + + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(false); + }); + it('should return true since 2nd "or" condition is true due to "or" inside is true', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + agreements: { analytics: true }, + scanThreshold: mockAppSettings.scanThreshold + 1, + }); + + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(true); + }); + it('should return false since all 2 or conditions are false', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + agreements: { analytics: false }, + }); + + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(false); + }); + it('should return true since 1st "or" condition is true', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + testValue: 'test', + agreements: { analytics: false }, + }); + + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(true); + }); + }); + + describe('checkFilter', () => { + it('should return false in case of any error', async () => { + const spy = jest.spyOn(service as any, 'checkAndFilters'); + spy.mockImplementationOnce(() => { throw new Error('some error on "and" filters'); }); + expect(await service['checkFilter'](Object.assign(new FeatureConfigFilterAnd(), {}), {})).toEqual(false); + }); + }); + + describe('checkAndFilters', () => { + let checkFilterSpy; + beforeEach(() => { + checkFilterSpy = jest.spyOn(service as any, 'checkFilter'); + }); + + it('should return true since all filters returned true', async () => { + checkFilterSpy.mockReturnValueOnce(true); + checkFilterSpy.mockReturnValueOnce(true); + checkFilterSpy.mockReturnValueOnce(true); + + expect(await service['checkAndFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(true); + }); + + it('should return false since at least one filter returned false', async () => { + checkFilterSpy.mockReturnValueOnce(true); + checkFilterSpy.mockReturnValueOnce(false); + checkFilterSpy.mockReturnValueOnce(true); + + expect(await service['checkAndFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(false); + }); + + it('should return false due to error', async () => { + checkFilterSpy.mockImplementation(() => { throw new Error('error when check filters'); }); + + expect(await service['checkAndFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(false); + }); + }); + + describe('checkOrFilters', () => { + let checkFilterSpy; + beforeEach(() => { + checkFilterSpy = jest.spyOn(service as any, 'checkFilter'); + }); + + it('should return true since at least one filter returned true', async () => { + checkFilterSpy.mockReturnValueOnce(false); + checkFilterSpy.mockReturnValueOnce(true); + checkFilterSpy.mockReturnValueOnce(false); + + expect(await service['checkOrFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(true); + }); + + it('should return false since all filters returned false', async () => { + checkFilterSpy.mockReturnValueOnce(false); + checkFilterSpy.mockReturnValueOnce(false); + checkFilterSpy.mockReturnValueOnce(false); + + expect(await service['checkOrFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(false); + }); + + it('should return false due to error', async () => { + checkFilterSpy.mockImplementation(() => { throw new Error('error when check filters'); }); + + expect(await service['checkOrFilters'](new Array(3).fill( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + ), {})).toEqual(false); + }); + }); + + describe('calculate', () => { + let isInTargetRangeSpy; + let filterSpy; + + beforeEach(() => { + isInTargetRangeSpy = jest.spyOn(service as any, 'isInTargetRange'); + filterSpy = jest.spyOn(service as any, 'filter'); + }); + + it('should return false since feature control number is out of range', async () => { + isInTargetRangeSpy.mockReturnValueOnce(false); + + expect(await service.calculate(mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations])) + .toEqual(false); + + expect(isInTargetRangeSpy).toHaveBeenCalledWith( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].perc, + ); + expect(filterSpy).not.toHaveBeenCalled(); + }); + + it('should return false since feature filters does not match', async () => { + isInTargetRangeSpy.mockReturnValueOnce(true); + filterSpy.mockReturnValueOnce(false); + + expect(await service.calculate(mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations])) + .toEqual(false); + + expect(isInTargetRangeSpy).toHaveBeenCalledWith( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].perc, + ); + expect(filterSpy).toHaveBeenCalledWith( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters, + ); + }); + it('should return true since all checks passes', async () => { + isInTargetRangeSpy.mockReturnValueOnce(true); + filterSpy.mockReturnValueOnce(true); + + expect(await service.calculate(mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations])) + .toEqual(true); + + expect(isInTargetRangeSpy).toHaveBeenCalledWith( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].perc, + ); + expect(filterSpy).toHaveBeenCalledWith( + mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters, + ); + }); + }); + + describe('DefaultFlagStrategy', () => { + it('should always return false', async () => { + const strategy = new DefaultFlagStrategy( + featuresConfigService as unknown as FeaturesConfigService, + settingsService as unknown as SettingsService, + ); + + expect(await strategy.calculate()).toEqual(false); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts new file mode 100644 index 0000000000..558604a596 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts @@ -0,0 +1,139 @@ +import { get } from 'lodash'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { + FeatureConfigFilter, FeatureConfigFilterAnd, + FeatureConfigFilterCondition, FeatureConfigFilterOr, + FeatureConfigFilterType, +} from 'src/modules/feature/model/features-config'; +import config from 'src/utils/config'; + +export abstract class FeatureFlagStrategy { + constructor( + protected readonly featuresConfigService: FeaturesConfigService, + protected readonly settingsService: SettingsService, + ) {} + + abstract calculate(data: any): Promise; + + /** + * Check if controlNumber is in defined range + * Should return false in case of any error + * @param perc + * @protected + */ + protected async isInTargetRange(perc: number[][] = [[-1]]): Promise { + try { + const { controlNumber } = await this.featuresConfigService.getControlInfo(); + + return !!perc.find((range) => controlNumber >= range[0] && controlNumber < range[1]); + } catch (e) { + return false; + } + } + + protected async getServerState(): Promise { + const state: any = { + config: config.get(), + env: process.env, + agreements: null, + settings: null, + }; + + // determine agreements and settings + try { + const appSettings = await this.settingsService.getAppSettings('1').catch(null); + + state.agreements = appSettings?.agreements; + state.settings = appSettings; + } catch (e) { + // silently ignore error + } + return state; + } + + /** + * Check all filters (starting from "AND" since { filters: [] } equal to filters: [{ and: []}]) + * @param filters + * @protected + */ + protected async filter(filters: FeatureConfigFilterType[]): Promise { + try { + const serverState = await this.getServerState(); + return this.checkAndFilters(filters, serverState); + } catch (e) { + return false; + } + } + + /** + * Check all feature filters with recursion + * @param filter + * @param serverState + * @private + */ + private checkFilter(filter: FeatureConfigFilterType, serverState: object): boolean { + try { + if (filter instanceof FeatureConfigFilterAnd) { + return this.checkAndFilters(filter.and, serverState); + } + + if (filter instanceof FeatureConfigFilterOr) { + return this.checkOrFilters(filter.or, serverState); + } + + if (filter instanceof FeatureConfigFilter) { + const value = get(serverState, filter?.name); + + switch (filter?.cond) { + case FeatureConfigFilterCondition.Eq: + return value === filter?.value; + case FeatureConfigFilterCondition.Neq: + return value !== filter?.value; + case FeatureConfigFilterCondition.Gt: + return value > filter?.value; + case FeatureConfigFilterCondition.Gte: + return value >= filter?.value; + case FeatureConfigFilterCondition.Lt: + return value < filter?.value; + case FeatureConfigFilterCondition.Lte: + return value <= filter?.value; + default: + return false; + } + } + } catch (e) { + // ignore error + } + + return false; + } + + /** + * Process "AND" filter when all of conditions (including in deep nested OR or AND) should pass + * @param filters + * @param serverState + * @private + */ + private checkAndFilters(filters: FeatureConfigFilterType[], serverState: object): boolean { + try { + return !!filters.every((filter) => this.checkFilter(filter, serverState)); + } catch (e) { + return false; + } + } + + /** + * Process "OR" conditions when at least one condition should pass + * @param filters + * @param serverState + * @private + */ + private checkOrFilters(filters: FeatureConfigFilterType[], serverState: object): boolean { + try { + return !!filters.some((filter) => this.checkFilter(filter, serverState)); + } catch (e) { + return false; + } + } +} diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts new file mode 100644 index 0000000000..7984ad533c --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts @@ -0,0 +1,9 @@ +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; + +export class InsightsRecommendationsFlagStrategy extends FeatureFlagStrategy { + async calculate(featureConfig: any): Promise { + const isInRange = await this.isInTargetRange(featureConfig?.perc); + + return isInRange && await this.filter(featureConfig?.filters) ? !!featureConfig?.flag : !featureConfig?.flag; + } +} diff --git a/redisinsight/api/src/modules/feature/repositories/feature.repository.ts b/redisinsight/api/src/modules/feature/repositories/feature.repository.ts new file mode 100644 index 0000000000..56a86b30a4 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/feature.repository.ts @@ -0,0 +1,8 @@ +import { Feature } from 'src/modules/feature/model/feature'; + +export abstract class FeatureRepository { + abstract get(name: string): Promise; + abstract upsert(feature: Feature): Promise; + abstract list(): Promise; + abstract delete(name: string): Promise; +} diff --git a/redisinsight/api/src/modules/feature/repositories/features-config.repository.ts b/redisinsight/api/src/modules/feature/repositories/features-config.repository.ts new file mode 100644 index 0000000000..9baae6b068 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/features-config.repository.ts @@ -0,0 +1,6 @@ +import { FeaturesConfig } from 'src/modules/feature/model/features-config'; + +export abstract class FeaturesConfigRepository { + abstract getOrCreate(): Promise; + abstract update(config: any): Promise; +} diff --git a/redisinsight/api/src/modules/feature/repositories/local.feature.repository.spec.ts b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.spec.ts new file mode 100644 index 0000000000..00b5da208c --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockFeature, + mockFeatureEntity, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { LocalFeatureRepository } from 'src/modules/feature/repositories/local.feature.repository'; +import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; + +describe('LocalFeatureRepository', () => { + let service: LocalFeatureRepository; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalFeatureRepository, + { + provide: getRepositoryToken(FeatureEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(FeatureEntity)); + service = await module.get(LocalFeatureRepository); + + repository.findOneBy.mockResolvedValue(mockFeatureEntity); + repository.find.mockResolvedValue([mockFeatureEntity, mockFeatureEntity, mockFeatureEntity]); + repository.upsert.mockResolvedValue({ updated: 1, inserted: 0 }); + repository.delete.mockResolvedValue({ deleted: 1 }); + }); + + describe('get', () => { + it('should return feature by name', async () => { + const result = await service.get(mockFeature.name); + + expect(result).toEqual(mockFeature); + }); + it('should return null when entity not found', async () => { + repository.findOneBy.mockResolvedValueOnce(null); + + const result = await service.get(mockFeature.name); + + expect(result).toEqual(null); + }); + }); + + describe('list', () => { + it('should return features', async () => { + const result = await service.list(); + + expect(result).toEqual([mockFeature, mockFeature, mockFeature]); + }); + it('should return empty list', async () => { + repository.find.mockResolvedValueOnce([]); + + const result = await service.list(); + + expect(result).toEqual([]); + }); + }); + + describe('upsert', () => { + it('should update or insert and return model', async () => { + const result = await service.upsert(mockFeature); + + expect(result).toEqual(mockFeature); + }); + }); + + describe('delete', () => { + it('should delete and do not return anything', async () => { + const result = await service.delete(mockFeature.name); + + expect(result).toEqual(undefined); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts new file mode 100644 index 0000000000..ec2df934e8 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { classToClass } from 'src/utils'; +import { FeatureRepository } from './feature.repository'; +import { FeatureEntity } from '../entities/feature.entity'; +import { Feature } from '../model/feature'; + +@Injectable() +export class LocalFeatureRepository extends FeatureRepository { + constructor( + @InjectRepository(FeatureEntity) + private readonly repository: Repository, + ) { + super(); + } + + /** + * @inheritDoc + */ + async get(name: string): Promise { + const entity = await this.repository.findOneBy({ name }); + return classToClass(Feature, entity); + } + + /** + * @inheritDoc + */ + async list(): Promise { + return (await this.repository.find()).map((entity) => classToClass(Feature, entity)); + } + + /** + * @inheritDoc + */ + async upsert(feature: Feature): Promise { + await this.repository.upsert(feature, { + skipUpdateIfNoValuesChanged: true, + conflictPaths: ['name'], + }); + + return this.get(feature.name); + } + + /** + * @inheritDoc + */ + async delete(name: string): Promise { + await this.repository.delete({ name }); + } +} diff --git a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts new file mode 100644 index 0000000000..70a91c7cd1 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockFeaturesConfig, + mockFeaturesConfigEntity, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { plainToClass } from 'class-transformer'; +import * as defaultConfig from '../../../../config/features-config.json'; + +describe('LocalFeaturesConfigRepository', () => { + let service: LocalFeaturesConfigRepository; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalFeaturesConfigRepository, + { + provide: getRepositoryToken(FeaturesConfigEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(FeaturesConfigEntity)); + service = await module.get(LocalFeaturesConfigRepository); + + repository.findOneBy.mockResolvedValue(mockFeaturesConfigEntity); + repository.update.mockResolvedValue({ updated: 1 }); + repository.save.mockResolvedValue(mockFeaturesConfigEntity); + }); + + describe('generateControlNumber', () => { + const step = 10; + const iterations = 10_000; + const delta = 100; + + it('check controlNumber generation', async () => { + const result = {}; + + for (let i = 0; i < 100; i += step) { + result[`${i} - ${i + step}`] = 0; + } + + (new Array(iterations)).fill(1).forEach(() => { + const controlNumber = service['generateControlNumber'](); + + expect(controlNumber).toBeGreaterThanOrEqual(0); + expect(controlNumber).toBeLessThan(100); + + for (let j = 0; j < 100; j += step) { + if (controlNumber <= (j + step)) { + result[`${j} - ${j + step}`] += 1; + break; + } + } + }); + + const amountPerGroup = iterations / step; + + Object.entries(result).forEach(([, value]) => { + expect(value).toBeGreaterThan(amountPerGroup - delta); + expect(value).toBeLessThan(amountPerGroup + delta); + }); + }); + }); + + describe('getOrCreate', () => { + it('should return existing config', async () => { + const result = await service.getOrCreate(); + + expect(result).toEqual(mockFeaturesConfig); + }); + it('should create new config', async () => { + repository.findOneBy.mockResolvedValueOnce(null); + + const result = await service.getOrCreate(); + + expect(result).toEqual(mockFeaturesConfig); + }); + }); + + describe('update', () => { + it('should update config', async () => { + const result = await service.update(defaultConfig); + + expect(result).toEqual(mockFeaturesConfig); + expect(repository.update).toHaveBeenCalledWith( + { id: service['id'] }, + plainToClass(FeaturesConfigEntity, { id: service['id'], data: defaultConfig }), + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts new file mode 100644 index 0000000000..98ce7a6926 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -0,0 +1,69 @@ +import { + Injectable, Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { plainToClass } from 'class-transformer'; +import { classToClass } from 'src/utils'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { FeaturesConfig } from 'src/modules/feature/model/features-config'; +import * as defaultConfig from '../../../../config/features-config.json'; + +@Injectable() +export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { + private readonly logger = new Logger('LocalFeaturesConfigRepository'); + + private readonly id = '1'; + + constructor( + @InjectRepository(FeaturesConfigEntity) + private readonly repository: Repository, + ) { + super(); + } + + /** + * Generate control number which should never be updated + * @private + */ + private generateControlNumber(): number { + const controlNumber = Number((parseInt((Math.random() * 10_000).toString(), 10) / 100).toFixed(2)); + this.logger.log('Control number is generated', controlNumber); + + return controlNumber; + } + + /** + * @inheritDoc + */ + async getOrCreate(): Promise { + this.logger.log('Getting features config entity'); + + let entity = await this.repository.findOneBy({ id: this.id }); + + if (!entity) { + this.logger.log('Creating features config entity'); + + entity = await this.repository.save(plainToClass(FeaturesConfigEntity, { + id: this.id, + data: defaultConfig, + controlNumber: this.generateControlNumber(), + })); + } + + return classToClass(FeaturesConfig, entity); + } + + /** + * @inheritDoc + */ + async update(data: any): Promise { + await this.repository.update( + { id: this.id }, + plainToClass(FeaturesConfigEntity, { data, id: this.id }), + ); + + return this.getOrCreate(); + } +} diff --git a/redisinsight/api/src/modules/feature/transformers/feature-config-filter.transformer.ts b/redisinsight/api/src/modules/feature/transformers/feature-config-filter.transformer.ts new file mode 100644 index 0000000000..9bd8eba9ab --- /dev/null +++ b/redisinsight/api/src/modules/feature/transformers/feature-config-filter.transformer.ts @@ -0,0 +1,21 @@ +import { get, map } from 'lodash'; +import { plainToClass } from 'class-transformer'; +import { + FeatureConfigFilter, + FeatureConfigFilterAnd, + FeatureConfigFilterOr, +} from 'src/modules/feature/model/features-config'; + +export const featureConfigFilterTransformer = (value) => map(value || [], (filter) => { + let cls: any = FeatureConfigFilter; + + if (get(filter, 'and')) { + cls = FeatureConfigFilterAnd; + } + + if (get(filter, 'or')) { + cls = FeatureConfigFilterOr; + } + + return plainToClass(cls, filter); +}); diff --git a/redisinsight/api/src/modules/feature/transformers/index.ts b/redisinsight/api/src/modules/feature/transformers/index.ts new file mode 100644 index 0000000000..2adec92b31 --- /dev/null +++ b/redisinsight/api/src/modules/feature/transformers/index.ts @@ -0,0 +1 @@ +export * from './feature-config-filter.transformer'; diff --git a/redisinsight/api/src/modules/server/dto/server.dto.ts b/redisinsight/api/src/modules/server/dto/server.dto.ts index ca59bb760c..ea68a68ac3 100644 --- a/redisinsight/api/src/modules/server/dto/server.dto.ts +++ b/redisinsight/api/src/modules/server/dto/server.dto.ts @@ -54,4 +54,16 @@ export class GetServerInfoResponse { type: Number, }) sessionId: number; + + @ApiProperty({ + description: 'Control number for A/B testing', + type: Number, + }) + controlNumber: number; + + @ApiProperty({ + description: 'Control group (bucket)', + type: String, + }) + controlGroup: string; } diff --git a/redisinsight/api/src/modules/server/server.module.ts b/redisinsight/api/src/modules/server/server.module.ts index 22d1215319..2404283122 100644 --- a/redisinsight/api/src/modules/server/server.module.ts +++ b/redisinsight/api/src/modules/server/server.module.ts @@ -3,6 +3,7 @@ import { ServerController } from 'src/modules/server/server.controller'; import { ServerService } from 'src/modules/server/server.service'; import { ServerRepository } from 'src/modules/server/repositories/server.repository'; import { LocalServerRepository } from 'src/modules/server/repositories/local.server.repository'; +import { FeatureModule } from 'src/modules/feature/feature.module'; @Module({}) export class ServerModule { @@ -19,6 +20,7 @@ export class ServerModule { useClass: serverRepository, }, ], + imports: [FeatureModule], }; } } diff --git a/redisinsight/api/src/modules/server/server.service.spec.ts b/redisinsight/api/src/modules/server/server.service.spec.ts index d68f740877..22c6fd6f9e 100644 --- a/redisinsight/api/src/modules/server/server.service.spec.ts +++ b/redisinsight/api/src/modules/server/server.service.spec.ts @@ -2,7 +2,15 @@ import { TestingModule, Test } from '@nestjs/testing'; import { InternalServerErrorException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Repository } from 'typeorm'; -import { mockEncryptionService, mockServer, mockServerRepository, MockType } from 'src/__mocks__'; +import { + mockControlGroup, + mockControlNumber, + mockEncryptionService, + mockFeaturesConfigService, + mockServer, + mockServerRepository, + MockType, +} from 'src/__mocks__'; import config from 'src/utils/config'; import { ServerInfoNotFoundException, @@ -16,6 +24,7 @@ import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { EncryptionStrategy } from 'src/modules/encryption/models'; import { ServerService } from 'src/modules/server/server.service'; import { ServerRepository } from 'src/modules/server/repositories/server.repository'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; const SERVER_CONFIG = config.get('server'); @@ -50,6 +59,10 @@ describe('ServerService', () => { provide: EncryptionService, useFactory: mockEncryptionService, }, + { + provide: FeaturesConfigService, + useFactory: mockFeaturesConfigService, + }, ], }).compile(); @@ -72,7 +85,13 @@ describe('ServerService', () => { expect(eventEmitter.emit).toHaveBeenNthCalledWith( 1, AppAnalyticsEvents.Initialize, - { anonymousId: mockServer.id, sessionId, appType: SERVER_CONFIG.buildType }, + { + anonymousId: mockServer.id, + sessionId, + appType: SERVER_CONFIG.buildType, + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }, ); expect(eventEmitter.emit).toHaveBeenNthCalledWith( 2, diff --git a/redisinsight/api/src/modules/server/server.service.ts b/redisinsight/api/src/modules/server/server.service.ts index d2efbb4093..0b9c3c60f1 100644 --- a/redisinsight/api/src/modules/server/server.service.ts +++ b/redisinsight/api/src/modules/server/server.service.ts @@ -10,6 +10,7 @@ import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { ServerRepository } from 'src/modules/server/repositories/server.repository'; import { AppType, BuildType } from 'src/modules/server/models/server'; import { GetServerInfoResponse } from 'src/modules/server/dto/server.dto'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; const SERVER_CONFIG = config.get('server'); const REDIS_STACK_CONFIG = config.get('redisStack'); @@ -22,6 +23,7 @@ export class ServerService implements OnApplicationBootstrap { constructor( private readonly repository: ServerRepository, + private readonly featuresConfigService: FeaturesConfigService, private readonly eventEmitter: EventEmitter2, private readonly encryptionService: EncryptionService, ) {} @@ -50,6 +52,7 @@ export class ServerService implements OnApplicationBootstrap { anonymousId: server.id, sessionId: this.sessionId, appType: this.getAppType(SERVER_CONFIG.buildType), + ...(await this.featuresConfigService.getControlInfo()), }); // do not track start events for non-electron builds @@ -85,6 +88,7 @@ export class ServerService implements OnApplicationBootstrap { appType: this.getAppType(SERVER_CONFIG.buildType), encryptionStrategies: await this.encryptionService.getAvailableEncryptionStrategies(), fixedDatabaseId: REDIS_STACK_CONFIG?.id, + ...(await this.featuresConfigService.getControlInfo()), }; this.logger.log('Succeed to get server info.'); return result; diff --git a/redisinsight/api/src/modules/settings/settings.service.spec.ts b/redisinsight/api/src/modules/settings/settings.service.spec.ts index 629e4767c3..0de8a7c5ea 100644 --- a/redisinsight/api/src/modules/settings/settings.service.spec.ts +++ b/redisinsight/api/src/modules/settings/settings.service.spec.ts @@ -5,7 +5,7 @@ import { mockAgreementsRepository, mockAppSettings, mockEncryptionStrategyInstance, mockSettings, mockSettingsAnalyticsService, mockSettingsRepository, - MockType, mockUserId + MockType, mockUserId, } from 'src/__mocks__'; import { UpdateSettingsDto } from 'src/modules/settings/dto/settings.dto'; import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; @@ -18,6 +18,8 @@ import { AgreementsRepository } from 'src/modules/settings/repositories/agreemen import { SettingsRepository } from 'src/modules/settings/repositories/settings.repository'; import { Agreements } from 'src/modules/settings/models/agreements'; import { Settings } from 'src/modules/settings/models/settings'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { FeatureServerEvents } from 'src/modules/feature/constants'; const REDIS_SCAN_CONFIG = config.get('redis_scan'); const WORKBENCH_CONFIG = config.get('workbench'); @@ -35,6 +37,7 @@ describe('SettingsService', () => { let settingsRepository: MockType; let analyticsService: SettingsAnalytics; let keytarStrategy: MockType; + let eventEmitter: EventEmitter2; beforeEach(async () => { jest.clearAllMocks(); @@ -57,6 +60,12 @@ describe('SettingsService', () => { provide: KeytarEncryptionStrategy, useFactory: mockEncryptionStrategyInstance, }, + { + provide: EventEmitter2, + useFactory: () => ({ + emit: jest.fn(), + }), + }, ], }).compile(); @@ -65,6 +74,7 @@ describe('SettingsService', () => { keytarStrategy = await module.get(KeytarEncryptionStrategy); analyticsService = await module.get(SettingsAnalytics); service = await module.get(SettingsService); + eventEmitter = await module.get(EventEmitter2); }); describe('getAppSettings', () => { @@ -80,6 +90,8 @@ describe('SettingsService', () => { batchSize: WORKBENCH_CONFIG.countBatch, agreements: null, }); + + expect(eventEmitter.emit).not.toHaveBeenCalled(); }); it('should return some application settings already defined by user', async () => { agreementsRepository.getOrCreate.mockResolvedValue(mockAgreements); @@ -129,6 +141,7 @@ describe('SettingsService', () => { }, }); expect(response).toEqual(mockAppSettings); + expect(eventEmitter.emit).toHaveBeenCalledWith(FeatureServerEvents.FeaturesRecalculate); }); it('should update agreements only', async () => { const dto: UpdateSettingsDto = { diff --git a/redisinsight/api/src/modules/settings/settings.service.ts b/redisinsight/api/src/modules/settings/settings.service.ts index 73f15b71a0..3fead32ff5 100644 --- a/redisinsight/api/src/modules/settings/settings.service.ts +++ b/redisinsight/api/src/modules/settings/settings.service.ts @@ -18,6 +18,8 @@ import { SettingsAnalytics } from 'src/modules/settings/settings.analytics'; import { SettingsRepository } from 'src/modules/settings/repositories/settings.repository'; import { classToClass } from 'src/utils'; import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; +import { FeatureServerEvents } from 'src/modules/feature/constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { GetAgreementsSpecResponse, GetAppSettingsResponse, UpdateSettingsDto } from './dto/settings.dto'; const SERVER_CONFIG = config.get('server'); @@ -31,6 +33,7 @@ export class SettingsService { private readonly agreementRepository: AgreementsRepository, private readonly analytics: SettingsAnalytics, private readonly keytarEncryptionStrategy: KeytarEncryptionStrategy, + private eventEmitter: EventEmitter2, ) {} /** @@ -86,6 +89,9 @@ export class SettingsService { this.logger.log('Succeed to update application settings.'); const results = await this.getAppSettings(userId); this.analytics.sendSettingsUpdatedEvent(results, oldAppSettings); + + this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculate); + return results; } catch (error) { this.logger.error('Failed to update application settings.', error); diff --git a/redisinsight/api/src/utils/config.ts b/redisinsight/api/src/utils/config.ts index a5a49fbd9a..9733f0d9ca 100644 --- a/redisinsight/api/src/utils/config.ts +++ b/redisinsight/api/src/utils/config.ts @@ -37,7 +37,7 @@ switch (process.env.BUILD_TYPE) { merge(config, envConfig, buildTypeConfig); -export const get = (key: string) => config[key]; +export const get = (key?: string) => (key ? config[key] : config); export default { get, diff --git a/redisinsight/api/test/api/notifications/WS-global-sync.test.ts b/redisinsight/api/test/api/_init/WS-notifications-global-sync.test.ts similarity index 100% rename from redisinsight/api/test/api/notifications/WS-global-sync.test.ts rename to redisinsight/api/test/api/_init/WS-notifications-global-sync.test.ts diff --git a/redisinsight/api/test/api/analytics/analytics.test.ts b/redisinsight/api/test/api/analytics/analytics.test.ts index 05ef06d346..658163567f 100644 --- a/redisinsight/api/test/api/analytics/analytics.test.ts +++ b/redisinsight/api/test/api/analytics/analytics.test.ts @@ -26,7 +26,7 @@ describe('Analytics', () => { fail('APPLICATION_STARTED or APPLICATION_FIRST_START events were not found'); } - expect(found?.properties).to.have.all.keys('appVersion', 'osPlatform', 'buildType'); + expect(found?.properties).to.have.all.keys('appVersion', 'osPlatform', 'buildType', 'controlNumber', 'controlGroup'); expect(found?.properties?.appVersion).to.be.a('string'); expect(found?.properties?.osPlatform).to.be.a('string'); expect(found?.properties?.buildType).to.be.a('string'); diff --git a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts index 0c4534dce3..4f57529b7e 100644 --- a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts +++ b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import-tutorial_data.test.ts @@ -56,7 +56,7 @@ describe('POST /databases/:id/bulk-actions/import/tutorial-data', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 1, succeed: 1, failed: 0, errors: [] }, progress: null, filter: null, @@ -91,7 +91,7 @@ describe('POST /databases/:id/bulk-actions/import/tutorial-data', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 1, succeed: 1, failed: 0, errors: [] }, progress: null, filter: null, diff --git a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts index d9d6b3056e..c1a5c49719 100644 --- a/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts +++ b/redisinsight/api/test/api/bulk-actions/POST-databases-id-bulk_actions-import.test.ts @@ -24,7 +24,7 @@ describe('POST /databases/:id/bulk-actions/import', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 1, succeed: 0, failed: 1, errors: [] }, progress: null, filter: null, @@ -50,7 +50,7 @@ describe('POST /databases/:id/bulk-actions/import', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 100, succeed: 100, failed: 0, errors: [] }, progress: null, filter: null, @@ -80,7 +80,7 @@ describe('POST /databases/:id/bulk-actions/import', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 10_000, succeed: 10_000, failed: 0, errors: [] }, progress: null, filter: null, @@ -115,7 +115,7 @@ describe('POST /databases/:id/bulk-actions/import', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 100, succeed: 50, failed: 50, errors: [] }, progress: null, filter: null, @@ -156,7 +156,7 @@ describe('POST /databases/:id/bulk-actions/import', () => { responseBody: { id: 'empty', databaseId: constants.TEST_INSTANCE_ID, - type: 'import', + type: 'upload', summary: { processed: 100_000, succeed: 100_000, failed: 0, errors: [] }, progress: null, filter: null, diff --git a/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts b/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts index 4f9206287c..7963e01464 100644 --- a/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts +++ b/redisinsight/api/test/api/database-recommendations/WS-new-recommendations.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, _, before, deps, validateApiCall, requirements } from '../deps'; const { server, request, constants, rte } = deps; import { + enableAllDbFeatures, getRepository, repositories } from '../../helpers/local-db'; import { Socket } from 'socket.io-client'; -import { randomBytes } from 'crypto'; import { getSocket } from '../../helpers/server'; const getClient = async (): Promise => { @@ -21,8 +21,9 @@ describe('WS new recommendations', () => { await repo.clear(); }); - before(() => { - rte.data.truncate(); + before(async () => { + await rte.data.truncate(); + await enableAllDbFeatures(); }); it('Should notify about new big set recommendations', async () => { @@ -39,7 +40,7 @@ describe('WS new recommendations', () => { validateApiCall({ endpoint: () => request(server).post(`/${constants.API.DATABASES}/${constants.TEST_INSTANCE_ID}/keys/get-info`), data: { - keys: [constants.TEST_SET_KEY_1], + keyName: constants.TEST_SET_KEY_1, }, }); }) @@ -49,7 +50,9 @@ describe('WS new recommendations', () => { expect(recommendationsResponse.recommendations[0].name).to.eq('bigSets'); expect(recommendationsResponse.recommendations[0].databaseId).to.eq(constants.TEST_INSTANCE_ID); expect(recommendationsResponse.recommendations[0].read).to.eq(false); - expect(recommendationsResponse.recommendations[0].disabled).to.eq(false); + // expect(recommendationsResponse.recommendations[0].disabled).to.eq(false); + // todo: investigate if it should return false vs undefined + expect(recommendationsResponse.recommendations[0].disabled).to.eq(undefined); expect(recommendationsResponse.totalUnread).to.eq(1); }); }); diff --git a/redisinsight/api/test/api/deps.ts b/redisinsight/api/test/api/deps.ts index 158cdc5bce..032e6e7892 100644 --- a/redisinsight/api/test/api/deps.ts +++ b/redisinsight/api/test/api/deps.ts @@ -7,6 +7,7 @@ import * as chai from 'chai'; import * as localDb from '../helpers/local-db'; import { constants } from '../helpers/constants'; import { getServer, getSocket } from '../helpers/server'; +import { initRemoteServer } from '../helpers/remote-server'; import { testEnv } from '../helpers/test'; import * as redis from '../helpers/redis'; import { initCloudDatabase } from '../helpers/cloud'; @@ -23,6 +24,8 @@ export async function depsInit () { // initialize analytics module deps.analytics = await getAnalytics(); + await initRemoteServer(); + // initializing backend server deps.server = await getServer(); diff --git a/redisinsight/api/test/api/feature/GET-features.test.ts b/redisinsight/api/test/api/feature/GET-features.test.ts new file mode 100644 index 0000000000..d6191c7c57 --- /dev/null +++ b/redisinsight/api/test/api/feature/GET-features.test.ts @@ -0,0 +1,183 @@ +import { + expect, + describe, + deps, + getMainCheckFn, fsExtra, before, after, +} from '../deps'; +import { constants } from '../../helpers/constants'; +import * as defaultConfig from '../../../config/features-config.json'; +import { getRepository, initSettings, repositories } from '../../helpers/local-db'; +const { getSocket, server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).get('/features'); +const syncEndpoint = () => request(server).post('/features/sync'); +const updateSettings = (data) => request(server).patch('/settings').send(data); + +const mainCheckFn = getMainCheckFn(endpoint); + + +const waitForFlags = async (flags: any) => { + const client = await getSocket(''); + + await new Promise((res, rej) => { + client.once('features', (data) => { + expect(flags).to.deep.eq(data); + res(true); + }) + setTimeout(() => { + rej(new Error('no flags received in 10s')); + }, 10000); + }); +}; + +let featureConfigRepository; +let featureRepository; +describe('GET /features', () => { + after(initSettings); + + before(async () => { + await initSettings(); + featureConfigRepository = await getRepository(repositories.FEATURES_CONFIG); + featureRepository = await getRepository(repositories.FEATURE); + }); + + [ + { + name: 'Should return false flag since no range was defined', + before: async () => { + await fsExtra.writeFile(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH, JSON.stringify({ + version: defaultConfig.version + 1, + features: { + insightsRecommendations: { + perc: [], + flag: true, + } + }, + })).catch(console.error); + + // remove all configs + await featureConfigRepository.delete({}); + await syncEndpoint(); + await waitForFlags({ + features: { + insightsRecommendations: { + flag: false, + }, + }, + }); + }, + statusCode: 200, + responseBody: { + features: { + insightsRecommendations: { + flag: false, + } + } + } + }, + { + name: 'Should return true since controlNumber is inside range', + before: async () => { + const [config, empty] = await featureConfigRepository.find(); + expect(empty).to.eq(undefined); + + await fsExtra.writeFile(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH, JSON.stringify({ + version: defaultConfig.version + 2, + features: { + insightsRecommendations: { + perc: [[config.controlNumber - 1, config.controlNumber + 1]], + flag: true, + } + }, + })).catch(console.error); + + // remove all configs + + await syncEndpoint(); + await waitForFlags({ + features: { + insightsRecommendations: { + flag: true, + }, + }, + }); + }, + statusCode: 200, + responseBody: { + features: { + insightsRecommendations: { + flag: true, + } + } + } + }, + { + name: 'Should return true since controlNumber is inside range and filters are match (analytics=true)', + before: async () => { + const [config, empty] = await featureConfigRepository.find(); + expect(empty).to.eq(undefined); + + await fsExtra.writeFile(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH, JSON.stringify({ + version: JSON.parse(config.data).version + 1, + features: { + insightsRecommendations: { + perc: [[config.controlNumber - 1, config.controlNumber + 1]], + flag: true, + filters: [{ + name: 'agreements.analytics', + value: true, + cond: 'eq', + }], + } + }, + })).catch(console.error); + + await syncEndpoint(); + await waitForFlags({ + features: { + insightsRecommendations: { + flag: true, + }, + }, + }); + }, + statusCode: 200, + responseBody: { + features: { + insightsRecommendations: { + flag: true, + } + } + } + }, + { + name: 'Should return false since analytics disabled (triggered by settings change)', + before: async () => { + await new Promise((res, rej) => { + waitForFlags({ + features: { + insightsRecommendations: { + flag: false, + }, + }, + }).then(res).catch(rej); + + updateSettings({ + agreements: { + analytics: false, + }, + }).catch(rej); + }); + }, + statusCode: 200, + responseBody: { + features: { + insightsRecommendations: { + flag: false, + } + } + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/feature/POST-features-sync.test.ts b/redisinsight/api/test/api/feature/POST-features-sync.test.ts new file mode 100644 index 0000000000..7c150df337 --- /dev/null +++ b/redisinsight/api/test/api/feature/POST-features-sync.test.ts @@ -0,0 +1,140 @@ +import { + expect, + before, + describe, + deps, + fsExtra, + getMainCheckFn, +} from '../deps'; +import { constants } from '../../helpers/constants'; +import * as defaultConfig from '../../../config/features-config.json'; +import { getRepository, repositories } from '../../helpers/local-db'; + +const { server, request } = deps; + +// endpoint to test +const endpoint = () => request(server).post('/features/sync'); + +const mainCheckFn = getMainCheckFn(endpoint); + +let featureConfigRepository; +let featureRepository; +describe('POST /features/sync', () => { + before(async () => { + featureConfigRepository = await getRepository(repositories.FEATURES_CONFIG); + featureRepository = await getRepository(repositories.FEATURE); + }); + + [ + { + name: 'Should sync with default config when db:null and remote:fail', + before: async () => { + // remove remote config so BE will get an error during fetching + await fsExtra.remove(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH).catch(console.error); + // remove all configs + await featureConfigRepository.delete({}); + + const [config] = await featureConfigRepository.find(); + expect(config).to.eq(undefined); + }, + statusCode: 200, + checkFn: async () => { + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.controlNumber).to.gte(0).lt(100); + expect(config.data).to.eq(JSON.stringify(defaultConfig)); + } + }, + { + name: 'Should sync with default config when db:version < default.version and remote:fail', + before: async () => { + await fsExtra.remove(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH).catch(console.error); + await featureConfigRepository.update({}, { + data: JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version - 0.1, + }), + }); + + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.data).to.eq(JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version - 0.1, + })); + }, + statusCode: 200, + checkFn: async () => { + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.controlNumber).to.gte(0).lt(100); + expect(config.data).to.eq(JSON.stringify(defaultConfig)); + } + }, + { + name: 'Should sync with remote config when db:null and remote:version > default.version', + before: async () => { + await fsExtra.writeFile(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH, JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version + 3.33, + })).catch(console.error); + + // remove all configs + await featureConfigRepository.delete({}); + + const [config] = await featureConfigRepository.find(); + + expect(config).to.eq(undefined); + }, + statusCode: 200, + checkFn: async () => { + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.controlNumber).to.gte(0).lt(100); + expect(config.data).to.eq(JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version + 3.33, + })); + } + }, + { + name: 'Should sync with remote config when db:version < default and remote:version > default', + before: async () => { + await fsExtra.writeFile(constants.TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH, JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version + 1.11, + })).catch(console.error); + // remove all configs + await featureConfigRepository.update({}, { + data: JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version - 0.1, + }), + }); + + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.data).to.eq(JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version - 0.1, + })); + }, + statusCode: 200, + checkFn: async () => { + const [config, empty] = await featureConfigRepository.find(); + + expect(empty).to.eq(undefined); + expect(config.controlNumber).to.gte(0).lt(100); + expect(config.data).to.eq(JSON.stringify({ + ...defaultConfig, + version: defaultConfig.version + 1.11, + })); + } + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/info/GET-info.test.ts b/redisinsight/api/test/api/info/GET-info.test.ts index af557dfdf6..b742c4379e 100644 --- a/redisinsight/api/test/api/info/GET-info.test.ts +++ b/redisinsight/api/test/api/info/GET-info.test.ts @@ -20,6 +20,8 @@ const responseSchema = Joi.object().keys({ appType: Joi.string().valid('ELECTRON', 'DOCKER', 'REDIS_STACK_WEB', 'UNKNOWN').required(), encryptionStrategies: Joi.array().items(Joi.string()), sessionId: Joi.number().required(), + controlNumber: Joi.number().required(), + controlGroup: Joi.string().required(), }).required(); const mainCheckFn = async (testCase) => { diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts index 1dc7c2eb2c..8442ae3b35 100644 --- a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts @@ -38,6 +38,7 @@ const responseSchema = Joi.object({ maxResults: Joi.number().integer().allow(null).required(), keys: Joi.array().items(Joi.object({ name: JoiRedisString.required(), + type: Joi.string(), })).required(), }).required().strict(true); const mainCheckFn = getMainCheckFn(endpoint); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index 45db34cbbe..7fc506f13e 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -41,6 +41,9 @@ export const constants = { TEST_KEYTAR_PASSWORD: process.env.SECRET_STORAGE_PASSWORD || 'somepassword', TEST_ENCRYPTION_STRATEGY: 'KEYTAR', TEST_AGREEMENTS_VERSION: '1.0.3', + TEST_REMOTE_STATIC_PATH: './remote', + TEST_REMOTE_STATIC_URI: '/remote', + TEST_FEATURE_FLAG_REMOTE_CONFIG_PATH: './remote/features-config.json', // local database TEST_LOCAL_DB_FILE_PATH: process.env.TEST_LOCAL_DB_FILE_PATH || './redisinsight.db', diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index c379caa9f3..051987af27 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -17,6 +17,8 @@ export const repositories = { DATABASE_RECOMMENDATION: 'DatabaseRecommendationEntity', BROWSER_HISTORY: 'BrowserHistoryEntity', CUSTOM_TUTORIAL: 'CustomTutorialEntity', + FEATURES_CONFIG: 'FeaturesConfigEntity', + FEATURE: 'FeatureEntity', } let localDbConnection; @@ -525,6 +527,7 @@ export const initAgreements = async () => { eula: true, encryption: constants.TEST_ENCRYPTION_STRATEGY === 'KEYTAR', analytics: true, + notifications: true, }); await rep.save(agreements); @@ -539,6 +542,14 @@ export const resetSettings = async () => { await rep.save(settings); } +export const enableAllDbFeatures = async () => { + const rep = await getRepository(repositories.FEATURE); + await rep.delete({}); + await rep.insert([ + { name: 'insightsRecommendations', flag: true }, + ]); +} + export const initSettings = async () => { await initAgreements(); const rep = await getRepository(repositories.SETTINGS); diff --git a/redisinsight/api/test/helpers/remote-server.ts b/redisinsight/api/test/helpers/remote-server.ts new file mode 100644 index 0000000000..c599077db3 --- /dev/null +++ b/redisinsight/api/test/helpers/remote-server.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +import * as fs from 'fs-extra'; +import { constants } from './constants'; +/** + * Initiate remote server to fetch various static data like notificaitons or features configs + */ +export const initRemoteServer = async () => { + await fs.ensureDir(constants.TEST_REMOTE_STATIC_PATH); + + const app = express(); + app.use(constants.TEST_REMOTE_STATIC_URI, express.static(constants.TEST_REMOTE_STATIC_PATH)) + await app.listen(5551, '0.0.0.0'); +} diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index e1cd1c31c6..5d8de66edc 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -8,11 +8,12 @@ import * as chai from 'chai'; import * as Joi from 'joi'; import * as AdmZip from 'adm-zip'; import * as diff from 'object-diff'; +import axios from 'axios'; import { cloneDeep, isMatch, isObject, set, isArray } from 'lodash'; import { generateInvalidDataArray } from './test/dataGenerator'; import serverConfig from 'src/utils/config'; -export { _, path, fs, fsExtra, AdmZip, serverConfig } +export { _, path, fs, fsExtra, AdmZip, serverConfig, axios } export const expect = chai.expect; export const testEnv: Record = {}; export { Joi, describe, it, before, after, beforeEach }; diff --git a/redisinsight/api/test/test-runs/docker.build.yml b/redisinsight/api/test/test-runs/docker.build.yml index fbde408d96..ccc5481646 100644 --- a/redisinsight/api/test/test-runs/docker.build.yml +++ b/redisinsight/api/test/test-runs/docker.build.yml @@ -41,6 +41,7 @@ services: APP_FOLDER_NAME: ".redisinsight-v2.0" SECRET_STORAGE_PASSWORD: "somepassword" NOTIFICATION_UPDATE_URL: "https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json" + RI_FEATURES_CONFIG_URL: "http://test:5551/remote/features-config.json" networks: default: diff --git a/redisinsight/api/test/test-runs/local.build.yml b/redisinsight/api/test/test-runs/local.build.yml index e8e04c9173..a6612dc1e0 100644 --- a/redisinsight/api/test/test-runs/local.build.yml +++ b/redisinsight/api/test/test-runs/local.build.yml @@ -20,7 +20,8 @@ services: environment: CERTS_FOLDER: "/root/.redisinsight-v2.0" TEST_REDIS_HOST: "redis" - NOTIFICATION_UPDATE_URL: "https://s3.amazonaws.com/redisinsight.test/public/tests/notifications.json" + NODE_ENV: "test" + REQUEST_TIMEOUT: "25000" # dummy service to prevent docker validation errors app: diff --git a/redisinsight/api/test/test-runs/start-test-run.sh b/redisinsight/api/test/test-runs/start-test-run.sh index c3dbb75568..77e5e567c5 100755 --- a/redisinsight/api/test/test-runs/start-test-run.sh +++ b/redisinsight/api/test/test-runs/start-test-run.sh @@ -58,7 +58,7 @@ echo "Test run is starting... ${RTE}" eval "ID=$ID RTE=$RTE docker-compose -p $ID \ -f $BASEDIR/$BUILD.build.yml \ -f $BASEDIR/$RTE/docker-compose.yml \ - --env-file $BASEDIR/$BUILD.build.env run test" + --env-file $BASEDIR/$BUILD.build.env run --use-aliases test" echo "Stop all containers... ${RTE}" eval "ID=$ID RTE=$RTE docker-compose -p $ID \ diff --git a/redisinsight/ui/src/components/config/Config.spec.tsx b/redisinsight/ui/src/components/config/Config.spec.tsx index b7134bd176..c4f4aa65de 100644 --- a/redisinsight/ui/src/components/config/Config.spec.tsx +++ b/redisinsight/ui/src/components/config/Config.spec.tsx @@ -2,7 +2,7 @@ import React from 'react' import { cloneDeep } from 'lodash' import { BuildType } from 'uiSrc/constants/env' import { localStorageService } from 'uiSrc/services' -import { setFeaturesToHighlight, setOnboarding } from 'uiSrc/slices/app/features' +import { getFeatureFlags, setFeaturesToHighlight, setOnboarding } from 'uiSrc/slices/app/features' import { getNotifications } from 'uiSrc/slices/app/notifications' import { render, mockedStore, cleanup, MOCKED_HIGHLIGHTING_FEATURES } from 'uiSrc/utils/test-utils' @@ -63,6 +63,7 @@ describe('Config', () => { getNotifications(), getWBGuides(), getWBTutorials(), + getFeatureFlags(), getUserConfigSettings(), ] expect(store.getActions()).toEqual([...afterRenderActions]) @@ -95,6 +96,7 @@ describe('Config', () => { getNotifications(), getWBGuides(), getWBTutorials(), + getFeatureFlags(), getUserConfigSettings(), setSettingsPopupState(true), ] diff --git a/redisinsight/ui/src/components/config/Config.tsx b/redisinsight/ui/src/components/config/Config.tsx index da0c53696b..1fec3b36c9 100644 --- a/redisinsight/ui/src/components/config/Config.tsx +++ b/redisinsight/ui/src/components/config/Config.tsx @@ -6,7 +6,7 @@ import { BrowserStorageItem } from 'uiSrc/constants' import { BuildType } from 'uiSrc/constants/env' import { BUILD_FEATURES } from 'uiSrc/constants/featuresHighlighting' import { localStorageService } from 'uiSrc/services' -import { setFeaturesToHighlight, setOnboarding } from 'uiSrc/slices/app/features' +import { fetchFeatureFlags, setFeaturesToHighlight, setOnboarding } from 'uiSrc/slices/app/features' import { fetchNotificationsAction } from 'uiSrc/slices/app/notifications' import { @@ -53,6 +53,8 @@ const Config = () => { dispatch(fetchGuides()) dispatch(fetchTutorials()) + dispatch(fetchFeatureFlags()) + // fetch config settings, after that take spec if (pathname !== SETTINGS_PAGE_PATH) { dispatch(fetchUserConfigSettings(() => dispatch(fetchUserSettingsSpec()))) diff --git a/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.spec.tsx b/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.spec.tsx new file mode 100644 index 0000000000..8149c07827 --- /dev/null +++ b/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render, screen } from 'uiSrc/utils/test-utils' + +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { FeatureFlags } from 'uiSrc/constants' + +import FeatureFlagComponent from './FeatureFlagComponent' + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + name: { + flag: false + } + }), +})) + +const InnerComponent = () => () +describe('FeatureFlagComponent', () => { + it('should not render component by default', () => { + render( + + + + ) + + expect(screen.queryByTestId('inner-component')).not.toBeInTheDocument() + }) + + it('should render component', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + name: { + flag: true + } + }) + + render( + + + + ) + + expect(screen.getByTestId('inner-component')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.tsx b/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.tsx new file mode 100644 index 0000000000..4a717b1b83 --- /dev/null +++ b/redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { FeatureFlags } from 'uiSrc/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' + +export interface Props { + name: FeatureFlags + children: React.ReactElement +} + +const FeatureFlagComponent = (props: Props) => { + const { children, name } = props + const { [name]: feature } = useSelector(appFeatureFlagsFeaturesSelector) + const { flag, variant } = feature ?? { flag: false } + + return flag ? React.cloneElement(children, { variant }) : null +} + +export default FeatureFlagComponent diff --git a/redisinsight/ui/src/components/feature-flag-component/index.ts b/redisinsight/ui/src/components/feature-flag-component/index.ts new file mode 100644 index 0000000000..0061aed91d --- /dev/null +++ b/redisinsight/ui/src/components/feature-flag-component/index.ts @@ -0,0 +1,3 @@ +import FeatureFlagComponent from './FeatureFlagComponent' + +export default FeatureFlagComponent diff --git a/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx b/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx index 757da3a2ac..e4726f72bf 100644 --- a/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx +++ b/redisinsight/ui/src/components/global-subscriptions/CommonAppSubscription/CommonAppSubscription.tsx @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { io, Socket } from 'socket.io-client' import { remove } from 'lodash' -import { SocketEvent } from 'uiSrc/constants' +import { SocketEvent, SocketFeaturesEvent } from 'uiSrc/constants' import { NotificationEvent } from 'uiSrc/constants/notifications' import { setNewNotificationAction } from 'uiSrc/slices/app/notifications' import { setIsConnected } from 'uiSrc/slices/app/socket-connection' @@ -11,6 +11,7 @@ import { getBaseApiUrl, Nullable } from 'uiSrc/utils' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import { setTotalUnread } from 'uiSrc/slices/recommendations/recommendations' import { RecommendationsSocketEvents } from 'uiSrc/constants/recommendations' +import { getFeatureFlagsSuccess } from 'uiSrc/slices/app/features' const CommonAppSubscription = () => { const { id: instanceId } = useSelector(connectedInstanceSelector) @@ -38,6 +39,13 @@ const CommonAppSubscription = () => { dispatch(setNewNotificationAction(data)) }) + socketRef.current.on(SocketFeaturesEvent.Features, (data) => { + dispatch(getFeatureFlagsSuccess(data)) + + // or + // dispatch(fetchFeatureFlags()) + }) + // Catch disconnect socketRef.current?.on(SocketEvent.Disconnect, () => { unSubscribeFromAllRecommendations() diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts index 9261f2c7ca..495a8350fb 100644 --- a/redisinsight/ui/src/components/index.ts +++ b/redisinsight/ui/src/components/index.ts @@ -25,6 +25,7 @@ import CodeBlock from './code-block' import ShowChildByCondition from './show-child-by-condition' import RecommendationVoting from './recommendation-voting' import RecommendationCopyComponent from './recommendation-copy-component' +import FeatureFlagComponent from './feature-flag-component' export { NavigationMenu, @@ -57,4 +58,5 @@ export { ShowChildByCondition, RecommendationVoting, RecommendationCopyComponent, + FeatureFlagComponent, } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index b10fef3a1c..1e9398d3c8 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -110,6 +110,8 @@ enum ApiEndpoints { REDISEARCH = 'redisearch', REDISEARCH_SEARCH = 'redisearch/search', HISTORY = 'history', + + FEATURES = 'features', } export const DEFAULT_SEARCH_MATCH = '*' diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts new file mode 100644 index 0000000000..e6b11f987f --- /dev/null +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -0,0 +1,3 @@ +export enum FeatureFlags { + insightsRecommendations = 'insightsRecommendations' +} diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index 2dd89c36c9..a424dea36d 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -25,4 +25,5 @@ export * from './durationUnits' export * from './streamViews' export * from './bulkActions' export * from './workbench' +export * from './featureFlags' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/socketEvents.ts b/redisinsight/ui/src/constants/socketEvents.ts index 903cdb29f9..303c58d6c3 100644 --- a/redisinsight/ui/src/constants/socketEvents.ts +++ b/redisinsight/ui/src/constants/socketEvents.ts @@ -3,3 +3,7 @@ export enum SocketEvent { Disconnect = 'disconnect', ConnectionError = 'connect_error', } + +export enum SocketFeaturesEvent { + Features = 'features' +} diff --git a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx index 6b73edf8f2..e895fc8508 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx @@ -6,6 +6,7 @@ import { instance, mock } from 'ts-mockito' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' import { BrowserStorageItem } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import InstancePage, { getDefaultSizes, Props } from './InstancePage' const mockedProps = mock() @@ -17,6 +18,15 @@ jest.mock('uiSrc/services', () => ({ }, })) +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + insightsRecommendations: { + flag: false + } + }), +})) + let store: typeof mockedStore beforeEach(() => { cleanup() @@ -50,7 +60,22 @@ describe('InstancePage', () => { expect(queryByTestId('expand-cli')).toBeInTheDocument() }) - it('should render with LiveTimeRecommendations Component', () => { + it('should not render LiveTimeRecommendations Component by default', () => { + const { queryByTestId } = render( + + + + ) + + expect(queryByTestId('recommendations-trigger')).not.toBeInTheDocument() + }) + + it('should render LiveTimeRecommendations Component with feature flag', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + insightsRecommendations: { + flag: true + } + }) const { queryByTestId } = render( diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index 07dc55baff..53a4b94bc3 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -6,12 +6,16 @@ import cx from 'classnames' import { setInitialAnalyticsSettings } from 'uiSrc/slices/analytics/settings' import { - fetchConnectedInstanceAction, fetchConnectedInstanceInfoAction, + fetchConnectedInstanceAction, + fetchConnectedInstanceInfoAction, fetchInstancesAction, getDatabaseConfigInfoAction, instancesSelector, } from 'uiSrc/slices/instances/instances' -import { fetchRecommendationsAction, resetRecommendationsHighlighting } from 'uiSrc/slices/recommendations/recommendations' +import { + fetchRecommendationsAction, + resetRecommendationsHighlighting +} from 'uiSrc/slices/recommendations/recommendations' import { appContextSelector, setAppContextConnectedInstanceId, @@ -19,8 +23,9 @@ import { setDbConfig, } from 'uiSrc/slices/app/context' import { resetPatternKeysData } from 'uiSrc/slices/browser/keys' -import { BrowserStorageItem } from 'uiSrc/constants' +import { BrowserStorageItem, FeatureFlags } from 'uiSrc/constants' import { localStorageService } from 'uiSrc/services' +import { FeatureFlagComponent } from 'uiSrc/components' import { resetOutput } from 'uiSrc/slices/cli/cli-output' import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' import BottomGroupComponents from 'uiSrc/components/bottom-group-components/BottomGroupComponents' @@ -123,7 +128,9 @@ const InstancePage = ({ routes = [] }: Props) => { return ( <> - + + + { + state.featureFlags.loading = true + }, + getFeatureFlagsSuccess: (state, { payload }) => { + state.featureFlags.loading = false + state.featureFlags.features = payload.features + }, + getFeatureFlagsFailure: (state) => { + state.featureFlags.loading = false + }, } }) @@ -92,7 +110,10 @@ export const { skipOnboarding, setOnboardPrevStep, setOnboardNextStep, - setOnboarding + setOnboarding, + getFeatureFlags, + getFeatureFlagsSuccess, + getFeatureFlagsFailure } = appFeaturesSlice.actions export const appFeatureSelector = (state: RootState) => state.app.features @@ -100,6 +121,8 @@ export const appFeatureHighlightingSelector = (state: RootState) => state.app.fe export const appFeaturePagesHighlightingSelector = (state: RootState) => state.app.features.highlighting.pages export const appFeatureOnboardingSelector = (state: RootState) => state.app.features.onboarding +export const appFeatureFlagsSelector = (state: RootState) => state.app.features.featureFlags +export const appFeatureFlagsFeaturesSelector = (state: RootState) => state.app.features.featureFlags.features export default appFeaturesSlice.reducer @@ -113,3 +136,26 @@ export function incrementOnboardStepAction(step: OnboardingSteps, skipCount = 0, } } } + +export function fetchFeatureFlags( + onSuccessAction?: (data: any) => void, + onFailAction?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(getFeatureFlags()) + + try { + const { data, status } = await apiService.get( + ApiEndpoints.FEATURES + ) + + if (isStatusSuccessful(status)) { + dispatch(getFeatureFlagsSuccess(data)) + onSuccessAction?.(data) + } + } catch (error) { + dispatch(getFeatureFlagsFailure()) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index e06ebec2f5..0bcc8a8bb9 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -1,7 +1,7 @@ import { AxiosError } from 'axios' import { RelativeWidthSizes } from 'uiSrc/components/virtual-table/interfaces' import { Nullable } from 'uiSrc/utils' -import { DurationUnits, ICommands } from 'uiSrc/constants' +import { DurationUnits, FeatureFlags, ICommands } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { GetServerInfoResponse } from 'apiSrc/modules/server/dto/server.dto' import { RedisString as RedisStringAPI } from 'apiSrc/common/constants/redis-string' @@ -147,6 +147,11 @@ export interface StateAppSocketConnection { isConnected: boolean } +export interface FeatureFlagComponent { + flag: boolean + variant?: string +} + export interface StateAppFeatures { highlighting: { version: string @@ -159,6 +164,12 @@ export interface StateAppFeatures { currentStep: number totalSteps: number isActive: boolean + }, + featureFlags: { + loading: boolean + features: { + [key in FeatureFlags]?: FeatureFlagComponent + } } } export enum NotificationType { diff --git a/redisinsight/ui/src/slices/tests/app/features.spec.ts b/redisinsight/ui/src/slices/tests/app/features.spec.ts index ce598b937a..68a0f879f3 100644 --- a/redisinsight/ui/src/slices/tests/app/features.spec.ts +++ b/redisinsight/ui/src/slices/tests/app/features.spec.ts @@ -9,7 +9,11 @@ import reducer, { skipOnboarding, setOnboardPrevStep, setOnboardNextStep, - incrementOnboardStepAction + incrementOnboardStepAction, + getFeatureFlags, + getFeatureFlagsSuccess, + getFeatureFlagsFailure, + fetchFeatureFlags } from 'uiSrc/slices/app/features' import { cleanup, @@ -18,6 +22,7 @@ import { mockedStore, mockStore } from 'uiSrc/utils/test-utils' +import { apiService } from 'uiSrc/services' let store: typeof mockedStore beforeEach(() => { @@ -366,6 +371,87 @@ describe('slices', () => { }) }) + describe('getFeatureFlags', () => { + it('should properly set state', () => { + const state = { + ...initialState, + featureFlags: { + ...initialState.featureFlags, + loading: true + } + } + + // Act + const nextState = reducer(initialState, getFeatureFlags()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { features: nextState }, + }) + + expect(appFeatureSelector(rootState)).toEqual(state) + }) + }) + + describe('getFeatureFlagsSuccess', () => { + it('should properly set state', () => { + const payload = { + features: { + insightsRecommendations: { + flag: true + } + } + } + const state = { + ...initialState, + featureFlags: { + ...initialState.featureFlags, + features: payload.features, + } + } + + // Act + const nextState = reducer(initialState, getFeatureFlagsSuccess(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { features: nextState }, + }) + + expect(appFeatureSelector(rootState)).toEqual(state) + }) + }) + + describe('getFeatureFlagsFailure', () => { + it('should properly set state', () => { + const currentState = { + ...initialState, + featureFlags: { + ...initialState.featureFlags, + loading: true + } + } + + const state = { + ...initialState, + featureFlags: { + ...initialState.featureFlags, + loading: false + } + } + + // Act + const nextState = reducer(currentState, getFeatureFlagsFailure()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + app: { features: nextState }, + }) + + expect(appFeatureSelector(rootState)).toEqual(state) + }) + }) + // thunks describe('incrementOnboardStepAction', () => { it('should call setOnboardNextStep', async () => { @@ -431,4 +517,48 @@ describe('slices', () => { expect(mockedStore.getActions()).toEqual([]) }) }) + + describe('fetchFeatureFlags', () => { + it('succeed to fetch data', async () => { + // Arrange + const data = { features: { insightsRecommendations: true } } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchFeatureFlags()) + + // Assert + const expectedActions = [ + getFeatureFlags(), + getFeatureFlagsSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchFeatureFlags()) + + // Assert + const expectedActions = [ + getFeatureFlags(), + getFeatureFlagsFailure(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) }) diff --git a/redisinsight/ui/src/telemetry/checkAnalytics.ts b/redisinsight/ui/src/telemetry/checkAnalytics.ts index 5ee4be792a..c38c2ac51e 100644 --- a/redisinsight/ui/src/telemetry/checkAnalytics.ts +++ b/redisinsight/ui/src/telemetry/checkAnalytics.ts @@ -5,4 +5,5 @@ import store from 'uiSrc/slices/store' export const checkIsAnalyticsGranted = () => !!get(store.getState(), 'user.settings.config.agreements.analytics', false) +export const getInfoServer = () => get(store.getState(), 'app.info.server', {}) export const getAppType = () => get(store.getState(), 'app.info.server.appType') diff --git a/redisinsight/ui/src/telemetry/interfaces.ts b/redisinsight/ui/src/telemetry/interfaces.ts index 87c4fe84c2..ee9243509b 100644 --- a/redisinsight/ui/src/telemetry/interfaces.ts +++ b/redisinsight/ui/src/telemetry/interfaces.ts @@ -8,7 +8,15 @@ export interface ITelemetryIdentify { export interface ITelemetryService { initialize(): Promise - pageView(name: string, appType: string, databaseId?: string): Promise + pageView( + name: string, + params: { + buildType?: string + controlNumber?: number + controlGroup?: string + databaseId?: string + } + ): Promise identify(opts: ITelemetryIdentify): Promise event(opts: ITelemetryEvent): Promise anonymousId: string diff --git a/redisinsight/ui/src/telemetry/segment.ts b/redisinsight/ui/src/telemetry/segment.ts index 035d02a7ff..db3b9b26c9 100644 --- a/redisinsight/ui/src/telemetry/segment.ts +++ b/redisinsight/ui/src/telemetry/segment.ts @@ -51,12 +51,15 @@ export class SegmentTelemetryService implements ITelemetryService { return this._anonymousId } - async pageView(name: string, appType: string, databaseId?: string): Promise { + async pageView( + name: string, + properties: object + ): Promise { return new Promise((resolve, reject) => { try { const pageInfo = this._getPageInfo() const { page = {} } = { ...pageInfo } - window.analytics.page(name, { databaseId, buildType: appType, ...page }, { + window.analytics.page(name, { ...properties, ...page }, { context: { ip: '0.0.0.0', ...pageInfo diff --git a/redisinsight/ui/src/telemetry/telemetryUtils.ts b/redisinsight/ui/src/telemetry/telemetryUtils.ts index 6217739b37..ed4659cc55 100644 --- a/redisinsight/ui/src/telemetry/telemetryUtils.ts +++ b/redisinsight/ui/src/telemetry/telemetryUtils.ts @@ -10,7 +10,7 @@ import { localStorageService } from 'uiSrc/services' import { ApiEndpoints, BrowserStorageItem, KeyTypes, StreamViews } from 'uiSrc/constants' import { KeyViewType } from 'uiSrc/slices/interfaces/keys' import { StreamViewType } from 'uiSrc/slices/interfaces/stream' -import { checkIsAnalyticsGranted, getAppType } from 'uiSrc/telemetry/checkAnalytics' +import { checkIsAnalyticsGranted, getInfoServer } from 'uiSrc/telemetry/checkAnalytics' import { AdditionalRedisModule } from 'apiSrc/modules/database/models/additional.redis.module' import { ITelemetrySendEvent, @@ -60,13 +60,15 @@ const sendEventTelemetry = (payload: ITelemetrySendEvent) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const appType = getAppType() + const { appType: buildType, controlNumber, controlGroup } = getInfoServer() as Record if (isAnalyticsGranted || nonTracking) { return telemetryService?.event({ event, properties: { - buildType: appType, + buildType, + controlNumber, + controlGroup, ...eventData, }, }) @@ -86,10 +88,19 @@ const sendPageViewTelemetry = (payload: ITelemetrySendPageView) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const appType = getAppType() + + const { appType: buildType, controlNumber, controlGroup } = getInfoServer() as Record if (isAnalyticsGranted || nonTracking) { - telemetryService?.pageView(name, appType, databaseId) + telemetryService?.pageView( + name, + { + buildType, + controlNumber, + controlGroup, + databaseId + } + ) } } diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 8ec1e0507d..719f1b3e1a 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -216,7 +216,8 @@ jest.mock( 'uiSrc/telemetry/checkAnalytics', () => ({ checkIsAnalyticsGranted: jest.fn(), - getAppType: jest.fn() + getAppType: jest.fn(), + getInfoServer: jest.fn().mockReturnValue({}), }) ) diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 256c0a563b..c5d5989d07 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -26,3 +26,6 @@ RE_CLUSTER_PORT=19443 NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/notifications.json NOTIFICATION_SYNC_INTERVAL=30000 + +RI_FEATURES_CONFIG_URL=http://localhost:5551/remote/features-config.json +RI_FEATURES_CONFIG_SYNC_INTERVAL=50000 diff --git a/tests/e2e/.env b/tests/e2e/.env index d0ccc363c5..9e354b1b99 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -4,3 +4,5 @@ OSS_SENTINEL_PASSWORD=password APP_FOLDER_NAME=.redisinsight-v2 NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/notifications.json NOTIFICATION_SYNC_INTERVAL=30000 +RI_FEATURES_CONFIG_URL=http://static-server:5551/remote/features-config.json +RI_FEATURES_CONFIG_SYNC_INTERVAL=50000 diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index 5a02d10815..c61413375a 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -1 +1,5 @@ plugins +report +results +remote +.redisinsight-v2 \ No newline at end of file diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 38cf5d0c14..63f3aba353 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -1,6 +1,15 @@ version: "3.4" services: + static-server: + build: + context: . + dockerfile: static-server.Dockerfile + volumes: + - ./remote:/app/remote + ports: + - 5551:5551 + e2e: build: context: . @@ -14,6 +23,7 @@ services: - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh + - ./remote:/root/remote env_file: - ./.env entrypoint: [ @@ -26,6 +36,7 @@ services: E2E_CLOUD_DATABASE_PASSWORD: $E2E_CLOUD_DATABASE_PASSWORD E2E_CLOUD_DATABASE_USERNAME: $E2E_CLOUD_DATABASE_USERNAME E2E_CLOUD_DATABASE_NAME: $E2E_CLOUD_DATABASE_NAME + REMOTE_FOLDER_PATH: "/root/remote" command: [ './wait-for-it.sh', 'redis-enterprise:12000', '-s', '-t', '120', '--', @@ -44,4 +55,3 @@ services: - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh - diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts new file mode 100644 index 0000000000..c327294ccc --- /dev/null +++ b/tests/e2e/helpers/api/api-info.ts @@ -0,0 +1,14 @@ +import { t } from 'testcafe'; +import * as request from 'supertest'; +import { Common } from '../common'; + +const endpoint = Common.getEndpoint(); + +/** + * Synchronize features + */ +export async function syncFeaturesApi(): Promise { + const response = await request(endpoint).post('/features/sync') + .set('Accept', 'application/json'); + await t.expect(response.status).eql(200, `Synchronization request failed: ${await response.body.message}`); +} diff --git a/tests/e2e/helpers/api/api-keys.ts b/tests/e2e/helpers/api/api-keys.ts index 295c299e30..69f7b4513e 100644 --- a/tests/e2e/helpers/api/api-keys.ts +++ b/tests/e2e/helpers/api/api-keys.ts @@ -1,6 +1,6 @@ import { t } from 'testcafe'; import * as request from 'supertest'; -import { AddNewDatabaseParameters } from '../../pageObjects/add-redis-database-page'; +import { AddNewDatabaseParameters } from '../../pageObjects/components/myRedisDatabase/add-redis-database'; import { Common } from '../../helpers/common'; import { HashKeyParameters, diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index 4a3bc52228..b1ecc24ba8 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -193,9 +193,17 @@ export class Common { } /** - * Create Zip archive from folder - * @param folderPath Path to folder to archive - * @param zipName Zip archive name + * Get json property value by property name and path + * @param expectedText Expected link that is compared with actual + */ + static async getJsonPropertyValue(property: string, path: string): Promise { + const parsedJson = JSON.parse(fs.readFileSync(path, 'utf-8')); + return parsedJson[property]; + } + /** + * Create Zip archive from folder + * @param folderPath Path to folder to archive + * @param zipName Zip archive name */ static async createZipFromFolder(folderPath: string, zipName: string): Promise { const sourceDir = path.join(__dirname, folderPath); diff --git a/tests/e2e/helpers/conf.ts b/tests/e2e/helpers/conf.ts index 544fa9246b..1b263f8d8e 100644 --- a/tests/e2e/helpers/conf.ts +++ b/tests/e2e/helpers/conf.ts @@ -8,6 +8,8 @@ const chance = new Chance(); export const commonUrl = process.env.COMMON_URL || 'https://localhost:5000'; export const apiUrl = process.env.API_URL || 'https://localhost:5000/api'; +export const workingDirectory = process.env.APP_FOLDER_ABSOLUTE_PATH + || (joinPath(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); export const fileDownloadPath = joinPath(os.homedir(), 'Downloads'); const uniqueId = chance.string({ length: 10 }); diff --git a/tests/e2e/helpers/database-scripts.ts b/tests/e2e/helpers/database-scripts.ts new file mode 100644 index 0000000000..9bc7ff95a9 --- /dev/null +++ b/tests/e2e/helpers/database-scripts.ts @@ -0,0 +1,70 @@ +import { workingDirectory } from '../helpers/conf'; +import * as sqlite3 from 'sqlite3'; + +const dbPath = `${workingDirectory}/redisinsight.db`; + +/** + * Update table column value into local DB + * @param tableName The name of table in DB + * @param columnName The name of column in table + * @param value Value to update in table + */ +export async function updateColumnValueInDBTable(tableName: string, columnName: string, value: number | string): Promise { + const db = new sqlite3.Database(dbPath); + const query = `UPDATE ${tableName} SET ${columnName} = ${value}`; + + return new Promise((resolve, reject) => { + db.run(query, (err: { message: string }) => { + if (err) { + reject(new Error(`Error during changing ${columnName} column value: ${err.message}`)); + } else { + db.close(); + resolve(); + } + }); + }); +} + +/** + * Get Column value from table in local Database + * @param tableName The name of table in DB + * @param columnName The name of column in table + */ +export async function getColumnValueFromTableInDB(tableName: string, columnName: string): Promise { + const db = new sqlite3.Database(dbPath); + const query = `SELECT ${columnName} FROM ${tableName}`; + + return new Promise((resolve, reject) => { + db.get(query, (err: { message: string }, row: any) => { + if (err) { + reject(new Error(`Error during getting ${columnName} column value: ${err.message}`)); + } else { + const columnValue = row[columnName]; + db.close(); + resolve(columnValue); + } + }); + }); +} + +/** + * Delete all rows from table in local DB + * @param tableName The name of table in DB + */ +export async function deleteRowsFromTableInDB(tableName: string): Promise { + const db = new sqlite3.Database(dbPath); + const query = `DELETE FROM ${tableName}`; + + return new Promise((resolve, reject) => { + + + db.run(query, (err: { message: string }) => { + if (err) { + reject(new Error(`Error during ${tableName} table rows deletion: ${err.message}`)); + } else { + db.close(); + resolve(); + } + }); + }); +} diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts new file mode 100644 index 0000000000..6a3709d83b --- /dev/null +++ b/tests/e2e/helpers/insights.ts @@ -0,0 +1,53 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { BasePage } from '../pageObjects'; +import { deleteRowsFromTableInDB, updateColumnValueInDBTable } from './database-scripts'; +import { syncFeaturesApi } from './api/api-info'; + +const basePage = new BasePage(); + +/** + * Update features-config file for static server + * @param filePath Path to feature config json + */ +export async function modifyFeaturesConfigJson(filePath: string): Promise { + const configFileName = 'features-config.json'; + const remoteConfigPath = process.env.REMOTE_FOLDER_PATH || './remote'; + const targetFilePath = path.join(remoteConfigPath, configFileName); + + return new Promise((resolve, reject) => { + try { + fs.ensureFileSync(targetFilePath); + fs.writeFileSync(targetFilePath, fs.readFileSync(filePath)); + resolve(); + } + catch (err) { + reject(new Error(`Error updating remote config file: ${err.message}`)); + } + }); +} + +/** + * Update Control Number of current user and sync + * @param controlNumber Control number to update + */ +export async function updateControlNumber(controlNumber: number): Promise { + const featuresConfigTable = 'features_config'; + + await syncFeaturesApi(); + await updateColumnValueInDBTable(featuresConfigTable, 'controlNumber', controlNumber); + await syncFeaturesApi(); + await basePage.reloadPage(); +} + +/** + * Refresh test data for features sync + */ +export async function refreshFeaturesTestData(): Promise { + const featuresConfigTable = 'features_config'; + const defaultConfigPath = path.join('.', 'test-data', 'features-configs', 'insights-default-remote.json'); + + await modifyFeaturesConfigJson(defaultConfigPath); + await deleteRowsFromTableInDB(featuresConfigTable); + await syncFeaturesApi(); +} diff --git a/tests/e2e/helpers/notifications.ts b/tests/e2e/helpers/notifications.ts index 289679469a..ad17268074 100644 --- a/tests/e2e/helpers/notifications.ts +++ b/tests/e2e/helpers/notifications.ts @@ -1,9 +1,6 @@ -import { join } from 'path'; -import * as os from 'os'; +import { workingDirectory} from '../helpers/conf'; import { NotificationParameters } from '../pageObjects/components/navigation/notification-panel'; -const workingDirectory = process.env.APP_FOLDER_ABSOLUTE_PATH - || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); const dbPath = `${workingDirectory}/redisinsight.db`; const sqlite3 = require('sqlite3').verbose(); diff --git a/tests/e2e/helpers/pub-sub.ts b/tests/e2e/helpers/pub-sub.ts index 1b0c067361..df98191d54 100644 --- a/tests/e2e/helpers/pub-sub.ts +++ b/tests/e2e/helpers/pub-sub.ts @@ -11,6 +11,6 @@ const pubSubPage = new PubSubPage(); export async function verifyMessageDisplayingInPubSub(message: string, displayed: boolean): Promise { const messageByText = pubSubPage.pubSubPageContainer.find(pubSubPage.cssSelectorMessage).withText(message); displayed - ? await t.expect(await messageByText.exists).ok(`"${message}" Message is not displayed`, { timeout: 5000 }) - : await t.expect(await messageByText.exists).notOk(`"${message}" Message is still displayed`); + ? await t.expect(messageByText.exists).ok(`"${message}" Message is not displayed`, { timeout: 5000 }) + : await t.expect(messageByText.exists).notOk(`"${message}" Message is still displayed`); } diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index 6f41a5d540..2335898771 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -1,6 +1,15 @@ version: "3.4" services: + static-server: + build: + context: . + dockerfile: static-server.Dockerfile + volumes: + - ./remote:/app/remote + ports: + - 5551:5551 + e2e: build: context: . @@ -12,6 +21,7 @@ services: - .redisinsight-v2:/root/.redisinsight-v2 - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh + - ./remote:/root/remote env_file: - ./.env environment: @@ -23,6 +33,7 @@ services: E2E_CLOUD_DATABASE_PASSWORD: $E2E_CLOUD_DATABASE_PASSWORD E2E_CLOUD_DATABASE_USERNAME: $E2E_CLOUD_DATABASE_USERNAME E2E_CLOUD_DATABASE_NAME: $E2E_CLOUD_DATABASE_NAME + REMOTE_FOLDER_PATH: "/root/remote" entrypoint: [ './upload-custom-plugins.sh', ] diff --git a/tests/e2e/package.json b/tests/e2e/package.json index cfb98c2d2e..05bed6c576 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -32,18 +32,23 @@ }, "devDependencies": { "@types/archiver": "^5.3.2", + "@types/axios": "^0.14.0", "@types/chance": "1.1.3", "@types/edit-json-file": "1.7.0", + "@types/fs-extra": "^11.0.1", + "@types/sqlite3": "^3.1.8", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", "archiver": "^5.3.1", + "axios": "^0.25.0", "chance": "1.1.8", "cross-env": "^7.0.3", "dotenv-cli": "^5.0.0", "edit-json-file": "1.7.0", "eslint": "7.32.0", "eslint-plugin-import": "2.24.2", + "fs-extra": "^11.1.1", "redis": "3.1.1", "sqlite3": "5.0.10", "supertest": "^4.0.2", diff --git a/tests/e2e/pageObjects/dialogs/onboarding-cards-dialog.ts b/tests/e2e/pageObjects/dialogs/onboarding-cards-dialog.ts index fcf130e1f8..d7b1d859d7 100644 --- a/tests/e2e/pageObjects/dialogs/onboarding-cards-dialog.ts +++ b/tests/e2e/pageObjects/dialogs/onboarding-cards-dialog.ts @@ -42,8 +42,8 @@ export class OnboardingCardsDialog { Complete onboarding process */ async completeOnboarding(): Promise { - await t.expect(await this.showMeAroundButton.exists).notOk('Show me around button still visible'); - await t.expect(await this.stepTitle.exists).notOk('Onboarding tooltip still visible'); + await t.expect(this.showMeAroundButton.exists).notOk('Show me around button still visible'); + await t.expect(this.stepTitle.exists).notOk('Onboarding tooltip still visible'); } /** Click back step diff --git a/tests/e2e/pageObjects/settings-page.ts b/tests/e2e/pageObjects/settings-page.ts index 8422443a9b..d6b4895386 100644 --- a/tests/e2e/pageObjects/settings-page.ts +++ b/tests/e2e/pageObjects/settings-page.ts @@ -89,7 +89,7 @@ export class SettingsPage extends BasePage { } /** - * Turn on notifications in Settings + * Turn on/off notifications in Settings */ async changeNotificationsSwitcher(toValue: boolean): Promise { await t.click(this.NavigationPanel.settingsButton); @@ -98,4 +98,14 @@ export class SettingsPage extends BasePage { await t.click(this.switchNotificationsOption); } } + + /** + * Turn on/off Analytics in Settings + */ + async changeAnalyticsSwitcher(toValue: boolean): Promise { + await t.click(this.accordionPrivacySettings); + if (toValue !== await this.getAnalyticsSwitcherValue()) { + await t.click(this.switchAnalyticsOption); + } + } } diff --git a/tests/e2e/static-server.Dockerfile b/tests/e2e/static-server.Dockerfile new file mode 100644 index 0000000000..795fff6937 --- /dev/null +++ b/tests/e2e/static-server.Dockerfile @@ -0,0 +1,9 @@ +FROM node:latest + +WORKDIR /app + +COPY package.json . +RUN yarn add express fs-extra +COPY . . + +CMD ["node", "static.ts"] \ No newline at end of file diff --git a/tests/e2e/static.ts b/tests/e2e/static.ts new file mode 100644 index 0000000000..879ef3ebe7 --- /dev/null +++ b/tests/e2e/static.ts @@ -0,0 +1,6 @@ +const express = require('express'); + +const app = express(); +app.use('/remote', express.static('./remote')); + +app.listen(5551); diff --git a/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json b/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json new file mode 100644 index 0000000000..e82e90b2e3 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json @@ -0,0 +1,35 @@ +{ + "version": 9, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + }, + { + "or": [ + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-build-type-filter.json b/tests/e2e/test-data/features-configs/insights-build-type-filter.json new file mode 100644 index 0000000000..56f4c595f5 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-build-type-filter.json @@ -0,0 +1,26 @@ +{ + "version": 15, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "REDIS_STACK", + "cond": "eq" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-default-remote.json b/tests/e2e/test-data/features-configs/insights-default-remote.json new file mode 100644 index 0000000000..876a75516d --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-default-remote.json @@ -0,0 +1,35 @@ +{ + "version": 0.9, + "features": { + "insightsRecommendations": { + "flag": true, + "perc": [ + [ + 0, + 20 + ] + ], + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "or": [ + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-docker-build.json b/tests/e2e/test-data/features-configs/insights-docker-build.json new file mode 100644 index 0000000000..969bdd4d51 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-docker-build.json @@ -0,0 +1,26 @@ +{ + "version": 11, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-electron-build.json b/tests/e2e/test-data/features-configs/insights-electron-build.json new file mode 100644 index 0000000000..b37f61526d --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-electron-build.json @@ -0,0 +1,26 @@ +{ + "version": 20, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-flag-off.json b/tests/e2e/test-data/features-configs/insights-flag-off.json new file mode 100644 index 0000000000..2f59fecf5d --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-flag-off.json @@ -0,0 +1,35 @@ +{ + "version": 17, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": false, + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + }, + { + "or": [ + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-invalid.json b/tests/e2e/test-data/features-configs/insights-invalid.json new file mode 100644 index 0000000000..118e6dcb36 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-invalid.json @@ -0,0 +1,29 @@ +{ + "version": 5, + "features": { + "insightsRecommendations": { + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "or": [ + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/features-configs/insights-valid.json b/tests/e2e/test-data/features-configs/insights-valid.json new file mode 100644 index 0000000000..ffa5f8f4b1 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-valid.json @@ -0,0 +1,35 @@ +{ + "version": 8, + "features": { + "insightsRecommendations": { + "perc": [ + [ + 44, + 50 + ] + ], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "or": [ + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/tests/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts b/tests/e2e/tests/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts index f919c2b677..68c0093939 100644 --- a/tests/e2e/tests/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts +++ b/tests/e2e/tests/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts @@ -4,15 +4,13 @@ import * as fs from 'fs'; import * as editJsonFile from 'edit-json-file'; import { acceptLicenseTermsAndAddDatabaseApi} from '../../../helpers/database'; import { MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; -import { commonUrl, ossStandaloneConfig } from '../../../helpers/conf'; +import { commonUrl, ossStandaloneConfig, workingDirectory } from '../../../helpers/conf'; import { rte, env } from '../../../helpers/constants'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); -const workingDirectory = process.env.APP_FOLDER_ABSOLUTE_PATH - || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); if (fs.existsSync(workingDirectory)) { // Guides content const guidesTimestampPath = `${workingDirectory}/guides/build.json`; diff --git a/tests/e2e/tests/critical-path/files-auto-update/promo-button-autoupdate.e2e.ts b/tests/e2e/tests/critical-path/files-auto-update/promo-button-autoupdate.e2e.ts index b2a673edf3..ea2957ae66 100644 --- a/tests/e2e/tests/critical-path/files-auto-update/promo-button-autoupdate.e2e.ts +++ b/tests/e2e/tests/critical-path/files-auto-update/promo-button-autoupdate.e2e.ts @@ -5,14 +5,12 @@ import { Chance } from 'chance'; import * as editJsonFile from 'edit-json-file'; import { acceptLicenseTerms } from '../../../helpers/database'; import { MyRedisDatabasePage } from '../../../pageObjects'; -import { commonUrl } from '../../../helpers/conf'; +import { commonUrl, workingDirectory } from '../../../helpers/conf'; import { env } from '../../../helpers/constants'; const myRedisDatabasePage = new MyRedisDatabasePage(); const chance = new Chance(); -const workingDirectory = process.env.APP_FOLDER_ABSOLUTE_PATH - || (join(os.homedir(), process.env.APP_FOLDER_NAME || '.redisinsight-v2')); if (fs.existsSync(workingDirectory)) { const timestampPromoButtonPath = `${workingDirectory}/content/build.json`; const contentPromoButtonPath = `${workingDirectory}/content/create-redis.json`; diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 35d3e2a16a..1c14fa519a 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -78,12 +78,6 @@ test await t.click(memoryEfficiencyPage.getRecommendationButtonByName(luaScriptRecommendation)); await t.expect(memoryEfficiencyPage.getRecommendationByName(luaScriptRecommendation).offsetHeight) .eql(expandedTextContaiterSize, 'Lua script recommendation not expanded'); - - // Verify that user can navigate by link to see the recommendation - await t.click(memoryEfficiencyPage.getRecommendationByName(luaScriptRecommendation).find(memoryEfficiencyPage.cssReadMoreLink)); - await Common.checkURL(externalPageLink); - // Close the window with external link to switch to the application window - await t.closeWindow(); }); // skipped due to inability to receive no recommendations for now test.skip('No recommendations message', async t => { diff --git a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts new file mode 100644 index 0000000000..7a225792a5 --- /dev/null +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -0,0 +1,150 @@ +import * as path from 'path'; +import { BrowserPage, MyRedisDatabasePage, SettingsPage } from '../../../pageObjects'; +import { RecommendationIds, rte, env } from '../../../helpers/constants'; +import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; +import { + addNewStandaloneDatabaseApi, + deleteStandaloneDatabaseApi +} from '../../../helpers/api/api-database'; +import { deleteRowsFromTableInDB, getColumnValueFromTableInDB } from '../../../helpers/database-scripts'; +import { modifyFeaturesConfigJson, refreshFeaturesTestData, updateControlNumber } from '../../../helpers/insights'; +import { Common } from '../../../helpers/common'; + +const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); +const settingsPage = new SettingsPage(); + +const featuresConfigTable = 'features_config'; +const redisVersionRecom = RecommendationIds.redisVersion; +const pathes = { + defaultRemote: path.join('.', 'test-data', 'features-configs', 'insights-default-remote.json'), + invalidConfig: path.join('.', 'test-data', 'features-configs', 'insights-invalid.json'), + validConfig: path.join('.', 'test-data', 'features-configs', 'insights-valid.json'), + analyticsConfig: path.join('.', 'test-data', 'features-configs', 'insights-analytics-filter-off.json'), + buildTypeConfig: path.join('.', 'test-data', 'features-configs', 'insights-build-type-filter.json'), + flagOffConfig: path.join('.', 'test-data', 'features-configs', 'insights-flag-off.json'), + dockerConfig: path.join('.', 'test-data', 'features-configs', 'insights-docker-build.json'), + electronConfig: path.join('.', 'test-data', 'features-configs', 'insights-electron-build.json') +}; + +fixture `Feature flag` + .meta({ type: 'regression', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await refreshFeaturesTestData(); + }) + .afterEach(async() => { + // Delete database + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + await refreshFeaturesTestData(); + }); +test('Verify that default config applied when remote config version is lower', async t => { + await updateControlNumber(19.2); + + const featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + + await t.expect(featureVersion).eql(1, 'Config with lowest version applied'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when disabled in default config'); +}); +test('Verify that invaid remote config not applied even if its version is higher than in the default config', async t => { + // Update remote config .json to invalid + await modifyFeaturesConfigJson(pathes.invalidConfig); + await updateControlNumber(19.2); + + const featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + + await t.expect(featureVersion).eql(1, 'Config highest version not applied'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when disabled in default config'); +}); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await addNewStandaloneDatabaseApi(ossStandaloneV5Config); + await refreshFeaturesTestData(); + }) + .after(async t => { + // Turn on telemetry + await t.click(browserPage.NavigationPanel.settingsButton); + await settingsPage.changeAnalyticsSwitcher(true); + // Delete databases connections + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Update remote config .json to default + await refreshFeaturesTestData(); + })('Verify that valid remote config applied with version higher than in the default config', async t => { + // Update remote config .json to valid + await modifyFeaturesConfigJson(pathes.validConfig); + await updateControlNumber(48.2); + let featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + let versionFromConfig = await Common.getJsonPropertyValue('version', pathes.validConfig); + + await t.expect(featureVersion).eql(versionFromConfig, 'Config with invalid data applied'); + // Verify that Insights panel displayed if user's controlNumber is in range from config file + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed when enabled from remote config'); + + // Verify that recommendations displayed for all databases if option enabled + await t.click(browserPage.OverviewPanel.myRedisDbIcon); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneV5Config.databaseName); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for the other db connection'); + await browserPage.InsightsPanel.toggleInsightsPanel(true); + await t.expect(browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); + + await browserPage.InsightsPanel.toggleInsightsPanel(false); + // Verify that Insights panel can be displayed for Telemetry enabled/disabled according to filters + await t.click(browserPage.NavigationPanel.settingsButton); + await settingsPage.changeAnalyticsSwitcher(false); + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed without analytics when its filter is on'); + + // Update remote config .json to config without analytics filter + await modifyFeaturesConfigJson(pathes.analyticsConfig); + await updateControlNumber(48.2); + // Verify that Insights panel can be displayed for WebStack app according to filters + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed without analytics when its filter is off'); + + // Verify that Insights panel not displayed if user's controlNumber is out of range from config file + await updateControlNumber(30.1); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); + + // Update remote config .json to config with buildType filter excluding current app build + await modifyFeaturesConfigJson(pathes.buildTypeConfig); + await updateControlNumber(48.2); + // Verify that buildType filter applied + featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + versionFromConfig = await Common.getJsonPropertyValue('version', pathes.buildTypeConfig); + await t.expect(featureVersion).eql(versionFromConfig, 'Config highest version not applied'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when filter excludes this buildType'); + + // Update remote config .json to config with insights feature disabled + await modifyFeaturesConfigJson(pathes.flagOffConfig); + await updateControlNumber(48.2); + // Verify that Insights panel not displayed if the remote config file has it disabled + featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + versionFromConfig = await Common.getJsonPropertyValue('version', pathes.flagOffConfig); + await t.expect(featureVersion).eql(versionFromConfig, 'Config highest version not applied'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when filter excludes this buildType'); + }); +test + .meta({ env: env.desktop }) + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Update remote config .json to default + await modifyFeaturesConfigJson(pathes.defaultRemote); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + })('Verify that Insights panel can be displayed for Electron app according to filters', async t => { + // Update remote config .json to config with buildType filter excluding current app build + await modifyFeaturesConfigJson(pathes.dockerConfig); + await updateControlNumber(48.2); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when filter excludes this buildType'); + + // Update remote config .json to config with buildType filter including current app build + await modifyFeaturesConfigJson(pathes.electronConfig); + await updateControlNumber(48.2); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed when filter includes this buildType'); + }); diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 02187bca72..bf43433c69 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -1,8 +1,10 @@ +import * as path from 'path'; import { BrowserPage, MemoryEfficiencyPage, MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; import { RecommendationIds, rte } from '../../../helpers/constants'; import { acceptLicenseTerms, acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; import { + addNewStandaloneDatabaseApi, addNewStandaloneDatabasesApi, deleteStandaloneDatabaseApi, deleteStandaloneDatabasesApi @@ -10,6 +12,7 @@ import { import { Common } from '../../../helpers/common'; import { Telemetry } from '../../../helpers/telemetry'; import { RecommendationsActions } from '../../../common-actions/recommendations-actions'; +import { modifyFeaturesConfigJson, refreshFeaturesTestData, updateControlNumber } from '../../../helpers/insights'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -33,6 +36,7 @@ const expectedProperties = [ 'provider', 'vote' ]; +const featuresConfig = path.join('.', 'test-data', 'features-configs', 'insights-valid.json'); const redisVersionRecom = RecommendationIds.redisVersion; const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; @@ -42,9 +46,16 @@ fixture `Live Recommendations` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async() => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await acceptLicenseTerms(); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); + await addNewStandaloneDatabaseApi(ossStandaloneConfig); + await myRedisDatabasePage.reloadPage(); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); }) .afterEach(async() => { + await refreshFeaturesTestData(); // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); @@ -52,6 +63,9 @@ test .before(async() => { // Add new databases using API await acceptLicenseTerms(); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); await addNewStandaloneDatabasesApi(databasesForAdding); // Reload Page await myRedisDatabasePage.reloadPage(); @@ -60,6 +74,7 @@ test .after(async() => { // Clear and delete database await browserPage.InsightsPanel.toggleInsightsPanel(false); + await refreshFeaturesTestData(); await browserPage.OverviewPanel.changeDbIndex(0); await browserPage.deleteKeyByName(keyName); await deleteStandaloneDatabasesApi(databasesForAdding); @@ -93,8 +108,15 @@ test test .requestHooks(logger) .before(async() => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await acceptLicenseTerms(); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); + await addNewStandaloneDatabaseApi(ossStandaloneV5Config); + await myRedisDatabasePage.reloadPage(); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneV5Config.databaseName); }).after(async() => { + await refreshFeaturesTestData(); await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that user can upvote recommendations', async() => { const notUsefulVoteOption = 'not useful'; @@ -166,9 +188,16 @@ test('Verify that user can snooze recommendation', async t => { }); test .before(async() => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await acceptLicenseTerms(); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); + await addNewStandaloneDatabaseApi(ossStandaloneV5Config); + await myRedisDatabasePage.reloadPage(); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneV5Config.databaseName); }).after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + await refreshFeaturesTestData(); })('Verify that recommendations from database analysis are displayed in Insight panel above live recommendations', async t => { const redisVersionRecomSelector = browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom); @@ -206,6 +235,7 @@ test('Verify that if user clicks on the Analyze button and link, the pop up with //https://redislabs.atlassian.net/browse/RI-4493 test .after(async() => { + await refreshFeaturesTestData(); await browserPage.deleteKeyByName(keyName); await deleteStandaloneDatabasesApi(databasesForAdding); })('Verify that key name is displayed for Insights and DA recommendations', async t => { diff --git a/tests/e2e/wait-for-it.sh b/tests/e2e/wait-for-it.sh index 5bd961eb15..8cd7575888 100755 --- a/tests/e2e/wait-for-it.sh +++ b/tests/e2e/wait-for-it.sh @@ -44,7 +44,7 @@ wait_for() echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" break fi - sleep 30 + sleep 60 done return $WAITFORIT_result } diff --git a/tests/e2e/wait-for-redis.sh b/tests/e2e/wait-for-redis.sh index 5d2ea95473..d78267f480 100755 --- a/tests/e2e/wait-for-redis.sh +++ b/tests/e2e/wait-for-redis.sh @@ -14,7 +14,7 @@ while [ $TIMEOUT -gt 0 ]; do exit 0; fi - sleep 1 + sleep 30 echo "Waiting... (left: $TIMEOUT)" done diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock index ae1d709db0..7f8bb98b30 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -1235,6 +1235,13 @@ dependencies: "@types/readdir-glob" "*" +"@types/axios@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46" + integrity sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ== + dependencies: + axios "*" + "@types/chance@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.3.tgz#d19fe9391288d60fdccd87632bfc9ab2b4523fea" @@ -1258,6 +1265,14 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe" integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg== +"@types/fs-extra@^11.0.1": + version "11.0.1" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-11.0.1.tgz#f542ec47810532a8a252127e6e105f487e0a6ea5" + integrity sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + "@types/glob@^7.1.1": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" @@ -1276,6 +1291,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonfile@*": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@types/jsonfile/-/jsonfile-6.1.1.tgz#ac84e9aefa74a2425a0fb3012bdea44f58970f1b" + integrity sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png== + dependencies: + "@types/node" "*" + "@types/lodash@4.14.192", "@types/lodash@^4.14.72": version "4.14.192" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.192.tgz#5790406361a2852d332d41635d927f1600811285" @@ -1303,6 +1325,13 @@ resolved "https://registry.yarnpkg.com/@types/set-value/-/set-value-4.0.1.tgz#7caf185556a67c2d9051080931853047423c93bd" integrity sha512-mP/CLy6pdrhsDVrz1+Yp5Ly6Tcel2IAEejhyI5NxY6WnBUdWN+AAfGa0HHsdgCdsPWWcd/4D5J2X2TrRYcYRag== +"@types/sqlite3@^3.1.8": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/sqlite3/-/sqlite3-3.1.8.tgz#e64310c5841fc01c1a8795d960d951e4cf940296" + integrity sha512-sQMt/qnyUWnqiTcJXm5ZfNPIBeJ/DVvJDwxw+0tAxPJvadzfiP1QhryO1JOR6t1yfb8NpzQb/Rud06mob5laIA== + dependencies: + "@types/node" "*" + "@types/superagent@*": version "4.1.17" resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-4.1.17.tgz#c8f0162b5d8a9c52d38b81398ef0650ef974b452" @@ -1704,6 +1733,22 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +axios@*: + version "1.4.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f" + integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + +axios@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.25.0.tgz#349cfbb31331a9b4453190791760a8d35b093e0a" + integrity sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g== + dependencies: + follow-redirects "^1.14.7" + babel-plugin-module-resolver@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/babel-plugin-module-resolver/-/babel-plugin-module-resolver-4.1.0.tgz#22a4f32f7441727ec1fbf4967b863e1e3e9f33e2" @@ -2092,7 +2137,7 @@ color-support@^1.1.2, color-support@^1.1.3: resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -combined-stream@^1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2985,6 +3030,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +follow-redirects@^1.14.7, follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" @@ -3006,6 +3056,15 @@ form-data@^2.3.1: combined-stream "^1.0.6" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + formidable@^1.2.0: version "1.2.6" resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" @@ -3023,6 +3082,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" + integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -3242,12 +3310,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.2.2, graceful-fs@^4.2.6: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -graceful-fs@^4.2.0: +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -3927,6 +3990,15 @@ json5@^2.1.0, json5@^2.2.2: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -4847,6 +4919,11 @@ promisify-event@^1.0.0: dependencies: pinkie-promise "^2.0.0" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + proxyquire@^1.7.10: version "1.8.0" resolved "https://registry.yarnpkg.com/proxyquire/-/proxyquire-1.8.0.tgz#02d514a5bed986f04cbb2093af16741535f79edc" @@ -4925,7 +5002,7 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" -readable-stream@^2.0.0, readable-stream@^2.0.5: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.3.5: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -4938,29 +5015,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^2.0.1, readable-stream@^2.3.5: - version "2.3.8" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.1.1, readable-stream@^3.4.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readable-stream@^3.6.0: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -5699,6 +5754,36 @@ testcafe-hammerhead@24.2.1: tunnel-agent "0.6.0" webauth "^1.1.0" +testcafe-hammerhead@>=19.4.0: + version "31.4.1" + resolved "https://registry.yarnpkg.com/testcafe-hammerhead/-/testcafe-hammerhead-31.4.1.tgz#36454e02d7abdf390cada50368139738b5a55c8f" + integrity sha512-1LqUfxRG5P1dKqgbXjPL0eS+cjCnMxHMZxD9RHJiMLZ8ddjnX+BjZ+LKHjsRpiAR9EBscQBwSkAuQYL2KR89Aw== + dependencies: + "@electron/asar" "^3.2.3" + acorn-hammerhead "0.6.1" + bowser "1.6.0" + crypto-md5 "^1.0.0" + css "2.2.3" + debug "4.3.1" + esotope-hammerhead "0.6.4" + http-cache-semantics "^4.1.0" + httpntlm "^1.8.10" + iconv-lite "0.5.1" + lodash "^4.17.20" + lru-cache "2.6.3" + match-url-wildcard "0.0.4" + merge-stream "^1.0.1" + mime "~1.4.1" + mustache "^2.1.1" + nanoid "^3.1.12" + os-family "^1.0.0" + parse5 "2.2.3" + pinkie "2.0.4" + read-file-relative "^1.2.0" + semver "5.5.0" + tough-cookie "4.0.0" + tunnel-agent "0.6.0" + testcafe-legacy-api@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/testcafe-legacy-api/-/testcafe-legacy-api-5.0.0.tgz#dde9dc2ee9e9490afed58b83df23cf2e01a6c303" @@ -5717,7 +5802,7 @@ testcafe-legacy-api@5.0.0: pinkie "^2.0.1" read-file-relative "^1.2.0" strip-bom "^2.0.0" - testcafe-hammerhead "24.2.1" + testcafe-hammerhead ">=19.4.0" testcafe-reporter-html@1.4.6: version "1.4.6" @@ -6110,6 +6195,11 @@ universalify@^0.1.2: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unquote@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544"