From 791e660252c6ab7db03c433e18f936f23d44835c Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Mon, 8 May 2023 16:12:43 +0200 Subject: [PATCH 01/55] #RI-4488 - fe part for feature flags --- .../ui/src/components/config/Config.tsx | 4 +- .../FeatureFlagComponent.spec.tsx | 45 ++++++ .../FeatureFlagComponent.tsx | 19 +++ .../feature-flag-component/index.ts | 3 + .../CommonAppSubscription.tsx | 10 +- redisinsight/ui/src/components/index.ts | 2 + redisinsight/ui/src/constants/api.ts | 2 + redisinsight/ui/src/constants/featureFlags.ts | 3 + redisinsight/ui/src/constants/index.ts | 1 + redisinsight/ui/src/constants/socketEvents.ts | 4 + .../src/pages/instance/InstancePage.spec.tsx | 27 +++- .../ui/src/pages/instance/InstancePage.tsx | 15 +- redisinsight/ui/src/slices/app/features.ts | 56 +++++++- redisinsight/ui/src/slices/interfaces/app.ts | 13 +- .../ui/src/slices/tests/app/features.spec.ts | 132 +++++++++++++++++- 15 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.spec.tsx create mode 100644 redisinsight/ui/src/components/feature-flag-component/FeatureFlagComponent.tsx create mode 100644 redisinsight/ui/src/components/feature-flag-component/index.ts create mode 100644 redisinsight/ui/src/constants/featureFlags.ts 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 3695f8ac1e..cb530c0165 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -109,6 +109,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..7ad469600d --- /dev/null +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -0,0 +1,3 @@ +export enum FeatureFlags { + liveRecommendations = 'liveRecommendations' +} 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..5674b8bec9 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({ + liveRecommendations: { + 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({ + liveRecommendations: { + 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..df3e1428b1 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 260801ade1..7623de7439 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' @@ -146,6 +146,11 @@ export interface StateAppSocketConnection { isConnected: boolean } +export interface FeatureFlagComponent { + flag: boolean + variant?: string +} + export interface StateAppFeatures { highlighting: { version: string @@ -158,6 +163,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..429adbf49b 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: { + liveRecommendations: { + 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: { liveRecommendations: 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) + }) + }) }) From 801e3ad5636aee10ffb809b1c41be702727789ed Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 10 May 2023 11:33:03 +0300 Subject: [PATCH 02/55] #RI-4489 initial implementation of features flags --- redisinsight/api/config/default.ts | 6 ++ redisinsight/api/config/features-config.json | 17 ++++ redisinsight/api/config/ormconfig.ts | 4 + .../api/src/constants/error-messages.ts | 1 + redisinsight/api/src/core.module.ts | 3 + .../modules/analytics/analytics.service.ts | 7 +- .../src/modules/feature/constants/index.ts | 24 +++++ .../feature/entities/feature.entity.ts | 19 ++++ .../entities/features-config.entity.ts | 25 ++++++ .../src/modules/feature/feature.controller.ts | 33 +++++++ .../src/modules/feature/feature.gateway.ts | 20 +++++ .../api/src/modules/feature/feature.module.ts | 45 ++++++++++ .../src/modules/feature/feature.service.ts | 90 +++++++++++++++++++ .../feature/features-config.service.ts | 82 +++++++++++++++++ .../api/src/modules/feature/model/feature.ts | 12 +++ .../modules/feature/model/features-config.ts | 9 ++ .../feature-flag/feature-flag.provider.ts | 29 ++++++ .../strategies/default.flag.strategy.ts | 7 ++ .../strategies/feature.flag.strategy.ts | 15 ++++ .../live-recommendations.flag.strategy.ts | 10 +++ .../repositories/feature.repository.ts | 8 ++ .../features-config.repository.ts | 6 ++ .../repositories/local.feature.repository.ts | 56 ++++++++++++ .../local.features-config.repository.ts | 78 ++++++++++++++++ .../api/src/modules/server/dto/server.dto.ts | 6 ++ .../modules/server/entities/server.entity.ts | 8 +- .../api/src/modules/server/server.module.ts | 2 + .../api/src/modules/server/server.service.ts | 4 + 28 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 redisinsight/api/config/features-config.json create mode 100644 redisinsight/api/src/modules/feature/constants/index.ts create mode 100644 redisinsight/api/src/modules/feature/entities/feature.entity.ts create mode 100644 redisinsight/api/src/modules/feature/entities/features-config.entity.ts create mode 100644 redisinsight/api/src/modules/feature/feature.controller.ts create mode 100644 redisinsight/api/src/modules/feature/feature.gateway.ts create mode 100644 redisinsight/api/src/modules/feature/feature.module.ts create mode 100644 redisinsight/api/src/modules/feature/feature.service.ts create mode 100644 redisinsight/api/src/modules/feature/features-config.service.ts create mode 100644 redisinsight/api/src/modules/feature/model/feature.ts create mode 100644 redisinsight/api/src/modules/feature/model/features-config.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/strategies/default.flag.strategy.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts create mode 100644 redisinsight/api/src/modules/feature/repositories/feature.repository.ts create mode 100644 redisinsight/api/src/modules/feature/repositories/features-config.repository.ts create mode 100644 redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts create mode 100644 redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index b22b8b106a..b80b0c03c9 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -202,4 +202,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..8f36ae3375 --- /dev/null +++ b/redisinsight/api/config/features-config.json @@ -0,0 +1,17 @@ +{ + "version": 12, + "features": { + "liveRecommendations": { + "version": 12, + "flag": true, + "perc": [[50, 60]], + "filter": [ + { + "name": "eula.analytics", + "value": true, + "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/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index dba176f778..7f90006036 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -64,4 +64,5 @@ export default { APP_SETTINGS_NOT_FOUND: () => 'Could not find application settings.', SERVER_INFO_NOT_FOUND: () => 'Could not find server info.', INCREASE_MINIMUM_LIMIT: (count: string) => `Set MAXSEARCHRESULTS to at least ${count}.`, + CONTROL_GROUP_NOT_EXIST: 'Control group not found.', }; 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.ts b/redisinsight/api/src/modules/analytics/analytics.service.ts index 83f2c16201..7850c8d2cd 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.ts @@ -19,6 +19,7 @@ export interface ITelemetryInitEvent { anonymousId: string; sessionId: number; appType: string; + controlGroup: number; } @Injectable() @@ -29,6 +30,8 @@ export class AnalyticsService { private appType: string = 'unknown'; + private controlGroup: number = -1; + private analytics; constructor( @@ -41,10 +44,11 @@ export class AnalyticsService { @OnEvent(AppAnalyticsEvents.Initialize) public initialize(payload: ITelemetryInitEvent) { - const { anonymousId, sessionId, appType } = payload; + const { anonymousId, sessionId, appType, controlGroup } = payload; this.sessionId = sessionId; this.anonymousId = anonymousId; this.appType = appType; + this.controlGroup = controlGroup; this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey, { flushInterval: ANALYTICS_CONFIG.flushInterval, }); @@ -75,6 +79,7 @@ export class AnalyticsService { properties: { ...eventData, buildType: this.appType, + controlGroup: this.controlGroup, }, }); } 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..a7441d1f95 --- /dev/null +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -0,0 +1,24 @@ +export enum FeatureServerEvents { + FeaturesRecalculate = 'FeaturesRecalculate', + FeaturesRecalculated = 'FeaturesRecalculated', +} + +export enum FeatureEvents { + Features = 'features', +} + +export enum FeatureStorage { + Env = 'env', + Database = 'database', +} + +export enum FeatureRecalculationStrategy { + LiveRecommendation = 'liveRecommendation', +} + +export const knownFeatures = [ + { + name: 'liveRecommendations', + 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..17cf94e4c6 --- /dev/null +++ b/redisinsight/api/src/modules/feature/entities/feature.entity.ts @@ -0,0 +1,19 @@ +import { + Column, Entity, PrimaryColumn, +} from 'typeorm'; +import { Expose } from 'class-transformer'; + +@Entity('features') +export class FeatureEntity { + @Expose() + @PrimaryColumn() + name: string; + + @Expose() + @Column() + flag: boolean; + + @Expose() + @Column() + version: number; +} 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..d58e385178 --- /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 }) + @Expose() + controlGroup: number; + + @Column({ nullable: false }) + @Expose() + @DataAsJsonString() + config: string; + + @UpdateDateColumn() + @Expose() + updatedAt: Date; +} 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..84d71f2346 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.controller.ts @@ -0,0 +1,33 @@ +import { + Controller, + Get, + 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'; + +@ApiTags('Info') +@Controller('features') +@UsePipes(new ValidationPipe({ transform: true })) +export class FeatureController { + constructor( + private featureService: FeatureService, + ) {} + + @Get('') + @ApiEndpoint({ + description: 'Get list of features', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Get list of features', + }, + ], + }) + async list(): Promise { + return this.featureService.list(); + } +} 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..96fe7598e5 --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.module.ts @@ -0,0 +1,45 @@ +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'; + +@Module({}) +export class FeatureModule { + static register( + featureRepository: Type = LocalFeatureRepository, + featuresConfigRepository: Type = LocalFeaturesConfigRepository, + ) { + return { + module: FeatureModule, + controllers: [FeatureController], + providers: [ + FeatureService, + FeaturesConfigService, + FeatureFlagProvider, + FeatureGateway, + { + provide: FeatureRepository, + useClass: featureRepository, + }, + { + provide: FeaturesConfigRepository, + useClass: featuresConfigRepository, + }, + ], + exports: [ + FeatureService, + FeaturesConfigService, + ], + imports: [ + NotificationModule, + ], + }; + } +} 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..4af5d6f5bf --- /dev/null +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -0,0 +1,90 @@ +import { find, map } 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'; + +@Injectable() +export class FeatureService { + private logger = new Logger('FeaturesConfigService'); + + constructor( + private repository: FeatureRepository, + private featuresConfigRepository: FeaturesConfigRepository, + private featureFlagProvider: FeatureFlagProvider, + private eventEmitter: EventEmitter2, + ) {} + + /** + * + */ + async list() { + this.logger.log('Getting features list'); + + const result = {}; + + 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) { + result[feature.name] = { flag: dbFeature.flag }; + } + } + }); + + return result; + } + + /** + * 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(map(featuresConfig?.config?.features || {}, async (feature, name) => { + const dbFeature = find(featuresFromDatabase, { name }); + + if (!dbFeature || feature?.version > dbFeature?.version) { + actions.toUpsert.push({ + name, + version: feature.version, + flag: await this.featureFlagProvider.calculate(name, feature), + }); + } + })); + + // calculate to delete features + actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.config?.features?.[feature.name]); + + // delete features + await Promise.all(actions.toDelete.map(this.repository.delete.bind(this))); + // upsert modified features + await Promise.all(actions.toUpsert.map(this.repository.upsert.bind(this))); + + 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.ts b/redisinsight/api/src/modules/feature/features-config.service.ts new file mode 100644 index 0000000000..4e14b452c1 --- /dev/null +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -0,0 +1,82 @@ +import axios from 'axios'; +import { + Injectable, Logger, NotFoundException, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import config from 'src/utils/config'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; +import { FeatureServerEvents } from 'src/modules/feature/constants'; + +const FEATURES_CONFIG = config.get('features_config'); + +@Injectable() +export class FeaturesConfigService { + private logger = new Logger('FeaturesConfigService'); + + constructor( + private repository: FeaturesConfigRepository, + private eventEmitter: EventEmitter2, + ) {} + + async onApplicationBootstrap() { + await this.sync(); + if (FEATURES_CONFIG.syncInterval > 0) { + setInterval(this.sync.bind(this), FEATURES_CONFIG.syncInterval); + } + } + + private async fetchRemoteConfig() { + try { + this.logger.log('Trying to fetch remote features config...'); + + const { data } = await axios.get(FEATURES_CONFIG.url, { + responseType: 'text', + transformResponse: [(raw) => raw], + }); + + return JSON.parse(data); + } catch (error) { + this.logger.error('Unable to fetch remote config', error); + } + + return null; + } + + /** + * Get latest config from remote and save it in the local database + */ + private async sync() { + try { + this.logger.log('Trying to sync features config...'); + + const featuresConfig = await this.repository.getOrCreate(); + const newConfig = await this.fetchRemoteConfig(); + + if (newConfig?.version > featuresConfig?.config?.version) { + await this.repository.update(newConfig); + } + + this.logger.log('Successfully updated stored remote config'); + } catch (error) { + this.logger.error('Unable to update features config', error); + } + + this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculate); + } + + /** + * Get control group field + */ + public async getControlGroup(): Promise { + try { + this.logger.debug('Trying to get controlGroup field'); + + const entity = await (this.repository.getOrCreate()); + return entity.controlGroup; + } catch (error) { + this.logger.error('Unable to get controlGroup field', error); + throw new NotFoundException(ERROR_MESSAGES.CONTROL_GROUP_NOT_EXIST); + } + } +} 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..d059087f63 --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/feature.ts @@ -0,0 +1,12 @@ +import { Expose } from 'class-transformer'; + +export class Feature { + @Expose() + version: number; + + @Expose() + name: string; + + @Expose() + flag: boolean; +} 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..6a260f9d6d --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/features-config.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class FeaturesConfig { + @Expose() + controlGroup: number; + + @Expose() + config: any; +} 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..5083a03a26 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; +import { + LiveRecommendationsFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/live-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'; + +@Injectable() +export class FeatureFlagProvider { + private strategies: Map = new Map(); + + constructor( + private readonly featuresConfigService: FeaturesConfigService, + ) { + this.strategies.set('default', new DefaultFlagStrategy(this.featuresConfigService)); + this.strategies.set('liveRecommendations', new LiveRecommendationsFlagStrategy(this.featuresConfigService)); + } + + 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.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts new file mode 100644 index 0000000000..4f90230693 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.ts @@ -0,0 +1,15 @@ +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; + +export abstract class FeatureFlagStrategy { + constructor( + protected readonly featuresConfigService: FeaturesConfigService, + ) {} + + abstract calculate(data: any): Promise; + + protected async isInTargetRange(perc: number[][] = [[-1]]): Promise { + const controlGroup = await this.featuresConfigService.getControlGroup(); + + return !!perc.find((range) => controlGroup >= range[0] && controlGroup < range[1]); + } +} diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts new file mode 100644 index 0000000000..f45f9b9d0d --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts @@ -0,0 +1,10 @@ +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; + +export class LiveRecommendationsFlagStrategy extends FeatureFlagStrategy { + async calculate(conditions: any): Promise { + const isInRange = await this.isInTargetRange(conditions?.perc); + + // todo: add filters + return isInRange ? !!conditions?.flag : !conditions?.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.ts b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts new file mode 100644 index 0000000000..15d1012fe9 --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts @@ -0,0 +1,56 @@ +import { + Injectable, Logger, +} 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 { + private readonly logger = new Logger('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 { + const entity = await this.repository.upsert(feature, { + skipUpdateIfNoValuesChanged: true, + conflictPaths: ['name'], + }); + + return classToClass(Feature, entity); + } + + /** + * @inheritDoc + */ + async delete(name: string): Promise { + await this.repository.delete({ name }); + } +} 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..2b9af8bffd --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -0,0 +1,78 @@ +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 group which should never be updated + * @private + */ + private generateControlGroup(): number { + this.logger.log('Getting control group'); + + const controlGroup = Number((Math.random() * 100).toFixed(2)); + + this.logger.log('Control group generated', controlGroup); + + return controlGroup; + } + + /** + * @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, + config: defaultConfig, + controlGroup: this.generateControlGroup(), + })); + } + + const model = classToClass(FeaturesConfig, entity); + + if (model?.config?.version < defaultConfig?.version) { + return this.update(defaultConfig); + } + + return model; + } + + /** + * @inheritDoc + */ + async update(config: any): Promise { + const entity = await this.repository.update( + { id: this.id }, + plainToClass(FeaturesConfigEntity, { config }), + ); + + return classToClass(FeaturesConfig, entity); + } +} diff --git a/redisinsight/api/src/modules/server/dto/server.dto.ts b/redisinsight/api/src/modules/server/dto/server.dto.ts index ca59bb760c..ddebe3f641 100644 --- a/redisinsight/api/src/modules/server/dto/server.dto.ts +++ b/redisinsight/api/src/modules/server/dto/server.dto.ts @@ -54,4 +54,10 @@ export class GetServerInfoResponse { type: Number, }) sessionId: number; + + @ApiProperty({ + description: 'Control group number for A/B testing', + type: Number, + }) + controlGroup: number; } diff --git a/redisinsight/api/src/modules/server/entities/server.entity.ts b/redisinsight/api/src/modules/server/entities/server.entity.ts index 26b7492c78..ab92e82ed9 100644 --- a/redisinsight/api/src/modules/server/entities/server.entity.ts +++ b/redisinsight/api/src/modules/server/entities/server.entity.ts @@ -1,4 +1,6 @@ -import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; +import { + Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, +} from 'typeorm'; import { Expose } from 'class-transformer'; @Entity('server') @@ -10,4 +12,8 @@ export class ServerEntity { @CreateDateColumn({ type: 'datetime', nullable: false }) @Expose() createDateTime: string; + + @Expose() + @Column({ nullable: true }) + controlGroup: number; } 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.ts b/redisinsight/api/src/modules/server/server.service.ts index d2efbb4093..326e2616a4 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), + controlGroup: await this.featuresConfigService.getControlGroup(), }); // 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, + controlGroup: await this.featuresConfigService.getControlGroup(), }; this.logger.log('Succeed to get server info.'); return result; From 5b1d863a44dcb0ae2c2d7831c1dc18e6f4671010 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 10 May 2023 11:54:51 +0300 Subject: [PATCH 03/55] change response structure --- redisinsight/api/src/modules/feature/feature.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/redisinsight/api/src/modules/feature/feature.service.ts b/redisinsight/api/src/modules/feature/feature.service.ts index 4af5d6f5bf..8f909cd596 100644 --- a/redisinsight/api/src/modules/feature/feature.service.ts +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -23,7 +23,7 @@ export class FeatureService { async list() { this.logger.log('Getting features list'); - const result = {}; + const features = {}; const featuresFromDatabase = await this.repository.list(); @@ -32,12 +32,12 @@ export class FeatureService { if (feature.storage === FeatureStorage.Database) { const dbFeature = find(featuresFromDatabase, { name: feature.name }); if (dbFeature) { - result[feature.name] = { flag: dbFeature.flag }; + features[feature.name] = { flag: dbFeature.flag }; } } }); - return result; + return { features }; } /** From 1860bbf34eabaaf8116a4532079754d4c18119d5 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Fri, 12 May 2023 13:30:58 +0200 Subject: [PATCH 04/55] #RI-4498 - update telemetry events fix tests --- .../ui/src/components/config/Config.spec.tsx | 4 ++- .../ui/src/telemetry/checkAnalytics.ts | 1 + redisinsight/ui/src/telemetry/interfaces.ts | 10 ++++++- redisinsight/ui/src/telemetry/segment.ts | 7 +++-- .../ui/src/telemetry/telemetryUtils.ts | 26 ++++++++++++++----- 5 files changed, 38 insertions(+), 10 deletions(-) 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/telemetry/checkAnalytics.ts b/redisinsight/ui/src/telemetry/checkAnalytics.ts index 5ee4be792a..f8ecd87f16 100644 --- a/redisinsight/ui/src/telemetry/checkAnalytics.ts +++ b/redisinsight/ui/src/telemetry/checkAnalytics.ts @@ -6,3 +6,4 @@ export const checkIsAnalyticsGranted = () => !!get(store.getState(), 'user.settings.config.agreements.analytics', false) export const getAppType = () => get(store.getState(), 'app.info.server.appType') +export const getControlGroup = () => get(store.getState(), 'app.info.server.controlGroup') diff --git a/redisinsight/ui/src/telemetry/interfaces.ts b/redisinsight/ui/src/telemetry/interfaces.ts index 87c4fe84c2..e06213eb51 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 + controlBucket?: string + controlGroup?: number + 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..b07066b437 100644 --- a/redisinsight/ui/src/telemetry/telemetryUtils.ts +++ b/redisinsight/ui/src/telemetry/telemetryUtils.ts @@ -3,14 +3,14 @@ * This module abstracts the exact service/framework used for tracking usage. */ import isGlob from 'is-glob' -import { cloneDeep } from 'lodash' +import { cloneDeep, isNumber } from 'lodash' import * as jsonpath from 'jsonpath' import { isRedisearchAvailable, Nullable } from 'uiSrc/utils' 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, getAppType, getControlGroup } from 'uiSrc/telemetry/checkAnalytics' import { AdditionalRedisModule } from 'apiSrc/modules/database/models/additional.redis.module' import { ITelemetrySendEvent, @@ -60,13 +60,17 @@ const sendEventTelemetry = (payload: ITelemetrySendEvent) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const appType = getAppType() + const buildType = getAppType() + const controlGroup = getControlGroup() + const controlBucket = isNumber(controlGroup) ? Math.floor(controlGroup).toString() : undefined if (isAnalyticsGranted || nonTracking) { return telemetryService?.event({ event, properties: { - buildType: appType, + buildType, + controlBucket, + controlGroup, ...eventData, }, }) @@ -86,10 +90,20 @@ const sendPageViewTelemetry = (payload: ITelemetrySendPageView) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const appType = getAppType() + const buildType = getAppType() + const controlGroup = getControlGroup() + const controlBucket = isNumber(controlGroup) ? Math.floor(controlGroup).toString() : undefined if (isAnalyticsGranted || nonTracking) { - telemetryService?.pageView(name, appType, databaseId) + telemetryService?.pageView( + name, + { + buildType, + controlBucket, + controlGroup, + databaseId + } + ) } } From 8d0188055072c8391f61a73b6d2f37e68a1f5f96 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Mon, 15 May 2023 09:22:43 +0200 Subject: [PATCH 05/55] #RI-4498 - update telemetry params --- redisinsight/ui/src/telemetry/checkAnalytics.ts | 2 +- redisinsight/ui/src/telemetry/interfaces.ts | 4 ++-- redisinsight/ui/src/telemetry/telemetryUtils.ts | 17 +++++++---------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/redisinsight/ui/src/telemetry/checkAnalytics.ts b/redisinsight/ui/src/telemetry/checkAnalytics.ts index f8ecd87f16..c38c2ac51e 100644 --- a/redisinsight/ui/src/telemetry/checkAnalytics.ts +++ b/redisinsight/ui/src/telemetry/checkAnalytics.ts @@ -5,5 +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') -export const getControlGroup = () => get(store.getState(), 'app.info.server.controlGroup') diff --git a/redisinsight/ui/src/telemetry/interfaces.ts b/redisinsight/ui/src/telemetry/interfaces.ts index e06213eb51..ee9243509b 100644 --- a/redisinsight/ui/src/telemetry/interfaces.ts +++ b/redisinsight/ui/src/telemetry/interfaces.ts @@ -12,8 +12,8 @@ export interface ITelemetryService { name: string, params: { buildType?: string - controlBucket?: string - controlGroup?: number + controlNumber?: number + controlGroup?: string databaseId?: string } ): Promise diff --git a/redisinsight/ui/src/telemetry/telemetryUtils.ts b/redisinsight/ui/src/telemetry/telemetryUtils.ts index b07066b437..ed4659cc55 100644 --- a/redisinsight/ui/src/telemetry/telemetryUtils.ts +++ b/redisinsight/ui/src/telemetry/telemetryUtils.ts @@ -3,14 +3,14 @@ * This module abstracts the exact service/framework used for tracking usage. */ import isGlob from 'is-glob' -import { cloneDeep, isNumber } from 'lodash' +import { cloneDeep } from 'lodash' import * as jsonpath from 'jsonpath' import { isRedisearchAvailable, Nullable } from 'uiSrc/utils' 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, getControlGroup } from 'uiSrc/telemetry/checkAnalytics' +import { checkIsAnalyticsGranted, getInfoServer } from 'uiSrc/telemetry/checkAnalytics' import { AdditionalRedisModule } from 'apiSrc/modules/database/models/additional.redis.module' import { ITelemetrySendEvent, @@ -60,16 +60,14 @@ const sendEventTelemetry = (payload: ITelemetrySendEvent) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const buildType = getAppType() - const controlGroup = getControlGroup() - const controlBucket = isNumber(controlGroup) ? Math.floor(controlGroup).toString() : undefined + const { appType: buildType, controlNumber, controlGroup } = getInfoServer() as Record if (isAnalyticsGranted || nonTracking) { return telemetryService?.event({ event, properties: { buildType, - controlBucket, + controlNumber, controlGroup, ...eventData, }, @@ -90,16 +88,15 @@ const sendPageViewTelemetry = (payload: ITelemetrySendPageView) => { const isAnalyticsGranted = checkIsAnalyticsGranted() setAnonymousId(isAnalyticsGranted) - const buildType = getAppType() - const controlGroup = getControlGroup() - const controlBucket = isNumber(controlGroup) ? Math.floor(controlGroup).toString() : undefined + + const { appType: buildType, controlNumber, controlGroup } = getInfoServer() as Record if (isAnalyticsGranted || nonTracking) { telemetryService?.pageView( name, { buildType, - controlBucket, + controlNumber, controlGroup, databaseId } From 8ce8cad12a1c0710bf069bba5ac4850ef17d6353 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 15 May 2023 11:31:29 +0300 Subject: [PATCH 06/55] #RI-4489 add filters to beta features + rework structure --- redisinsight/api/config/features-config.json | 9 ++- redisinsight/api/src/__mocks__/feature.ts | 59 +++++++++++++++ redisinsight/api/src/__mocks__/index.ts | 1 + .../api/src/common/decorators/index.ts | 2 + .../decorators/is-multi-number.decorator.ts | 17 +++++ .../decorators/object-as-map.decorator.ts | 35 +++++++++ .../api/src/common/validators/index.ts | 1 + .../validators/multi-number.validator.ts | 27 +++++++ .../feature/entities/feature.entity.ts | 4 -- .../entities/features-config.entity.ts | 4 +- .../src/modules/feature/feature.service.ts | 17 ++--- .../feature/features-config.service.spec.ts | 40 +++++++++++ .../feature/features-config.service.ts | 14 +++- .../api/src/modules/feature/model/feature.ts | 3 - .../modules/feature/model/features-config.ts | 65 ++++++++++++++++- .../feature-flag/feature-flag.provider.ts | 12 +++- .../strategies/feature.flag.strategy.spec.ts | 58 +++++++++++++++ .../strategies/feature.flag.strategy.ts | 62 +++++++++++++++- .../live-recommendations.flag.strategy.ts | 7 +- .../local.features-config.repository.spec.ts | 72 +++++++++++++++++++ .../local.features-config.repository.ts | 6 +- .../api/src/modules/server/dto/server.dto.ts | 10 ++- .../api/src/modules/server/server.service.ts | 4 +- .../src/modules/settings/settings.service.ts | 6 ++ 24 files changed, 489 insertions(+), 46 deletions(-) create mode 100644 redisinsight/api/src/__mocks__/feature.ts create mode 100644 redisinsight/api/src/common/decorators/is-multi-number.decorator.ts create mode 100644 redisinsight/api/src/common/decorators/object-as-map.decorator.ts create mode 100644 redisinsight/api/src/common/validators/multi-number.validator.ts create mode 100644 redisinsight/api/src/modules/feature/features-config.service.spec.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts create mode 100644 redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 8f36ae3375..d1700e47b4 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,13 +1,12 @@ { - "version": 12, + "version": 1, "features": { "liveRecommendations": { - "version": 12, "flag": true, - "perc": [[50, 60]], - "filter": [ + "perc": [[0, 100]], + "filters": [ { - "name": "eula.analytics", + "name": "agreements.analytics", "value": true, "cond": "eq" } diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts new file mode 100644 index 0000000000..8b7b21de1b --- /dev/null +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -0,0 +1,59 @@ +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { FeaturesConfig, FeaturesConfigData } from 'src/modules/feature/model/features-config'; +import { plainToClass } from 'class-transformer'; + +export const mockFeaturesConfigId = '1'; +export const mockControlNumber = 7.68; + +export const mockFeaturesConfigJson = { + version: 1, + features: { + liveRecommendations: { + perc: [[0, 10]], + flag: true, + filters: [ + { + name: 'agreements.analytics', + value: true, + cond: 'eq', + }, + ], + }, + }, +}; + +export const mockFeaturesConfigData = plainToClass(FeaturesConfigData, { + version: mockFeaturesConfigJson.version, + features: { + liveRecommendations: { + perc: [[0, 10]], + flag: true, + filters: [ + { + name: 'agreements.analytics', + value: true, + cond: 'eq', + }, + ], + }, + }, +}); + +export const mockFeaturesConfig = Object.assign(new FeaturesConfig(), { + controlNumber: mockControlNumber, + data: mockFeaturesConfigData, +}); + +export const mockFeaturesConfigEntity = Object.assign(new FeaturesConfigEntity(), { + id: mockFeaturesConfigId, + data: JSON.stringify(mockFeaturesConfig), +}); + +export const mockFeaturesConfigRepository = jest.fn(() => ({ + getOrCreate: jest.fn(), + update: jest.fn(), +})); + +export const mockFeaturesConfigService = () => ({ + sync: jest.fn(), +}); 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..2e4281ea9b --- /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((object): object => { + try { + const result = {}; + + forEach(object, (value, key) => { + 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/modules/feature/entities/feature.entity.ts b/redisinsight/api/src/modules/feature/entities/feature.entity.ts index 17cf94e4c6..a728c1d487 100644 --- a/redisinsight/api/src/modules/feature/entities/feature.entity.ts +++ b/redisinsight/api/src/modules/feature/entities/feature.entity.ts @@ -12,8 +12,4 @@ export class FeatureEntity { @Expose() @Column() flag: boolean; - - @Expose() - @Column() - version: number; } diff --git a/redisinsight/api/src/modules/feature/entities/features-config.entity.ts b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts index d58e385178..08184277ef 100644 --- a/redisinsight/api/src/modules/feature/entities/features-config.entity.ts +++ b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts @@ -12,12 +12,12 @@ export class FeaturesConfigEntity { @Column({ nullable: true }) @Expose() - controlGroup: number; + controlNumber: number; @Column({ nullable: false }) @Expose() @DataAsJsonString() - config: string; + data: string; @UpdateDateColumn() @Expose() diff --git a/redisinsight/api/src/modules/feature/feature.service.ts b/redisinsight/api/src/modules/feature/feature.service.ts index 8f909cd596..11f92c0f36 100644 --- a/redisinsight/api/src/modules/feature/feature.service.ts +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -58,20 +58,15 @@ export class FeatureService { this.logger.debug('Recalculating features flags for new config', featuresConfig); - await Promise.all(map(featuresConfig?.config?.features || {}, async (feature, name) => { - const dbFeature = find(featuresFromDatabase, { name }); - - if (!dbFeature || feature?.version > dbFeature?.version) { - actions.toUpsert.push({ - name, - version: feature.version, - flag: await this.featureFlagProvider.calculate(name, feature), - }); - } + 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?.config?.features?.[feature.name]); + actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.data?.features?.[feature.name]); // delete features await Promise.all(actions.toDelete.map(this.repository.delete.bind(this))); 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..dd312683c0 --- /dev/null +++ b/redisinsight/api/src/modules/feature/features-config.service.spec.ts @@ -0,0 +1,40 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InternalServerErrorException } from '@nestjs/common'; +import { + mockFeaturesConfigRepository, + MockType +} from 'src/__mocks__'; +import config from 'src/utils/config'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; +import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); +const WORKBENCH_CONFIG = config.get('workbench'); + +describe('FeaturesConfigService', () => { + let service: FeaturesConfigService; + let repository: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeaturesConfigService, + { + provide: FeaturesConfigRepository, + useFactory: mockFeaturesConfigRepository, + } + ], + }).compile(); + + service = module.get(FeaturesConfigService); + }); + + describe('sync', () => { + it('should sync', async () => { + await service['sync'](); + }); + }); +}); diff --git a/redisinsight/api/src/modules/feature/features-config.service.ts b/redisinsight/api/src/modules/feature/features-config.service.ts index 4e14b452c1..128d0d680e 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -7,6 +7,7 @@ import config from 'src/utils/config'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; import { FeatureServerEvents } from 'src/modules/feature/constants'; +import { Validator } from 'class-validator'; const FEATURES_CONFIG = config.get('features_config'); @@ -14,6 +15,8 @@ const FEATURES_CONFIG = config.get('features_config'); export class FeaturesConfigService { private logger = new Logger('FeaturesConfigService'); + private validator = new Validator(); + constructor( private repository: FeaturesConfigRepository, private eventEmitter: EventEmitter2, @@ -53,7 +56,9 @@ export class FeaturesConfigService { const featuresConfig = await this.repository.getOrCreate(); const newConfig = await this.fetchRemoteConfig(); - if (newConfig?.version > featuresConfig?.config?.version) { + await this.validator.validateOrReject(newConfig); + + if (newConfig?.version > featuresConfig?.data?.version) { await this.repository.update(newConfig); } @@ -68,12 +73,15 @@ export class FeaturesConfigService { /** * Get control group field */ - public async getControlGroup(): Promise { + public async getControlInfo(): Promise<{ controlNumber: number, controlGroup: string }> { try { this.logger.debug('Trying to get controlGroup field'); const entity = await (this.repository.getOrCreate()); - return entity.controlGroup; + return { + controlNumber: entity.controlNumber, + controlGroup: entity.controlNumber.toFixed(0), + }; } catch (error) { this.logger.error('Unable to get controlGroup field', error); throw new NotFoundException(ERROR_MESSAGES.CONTROL_GROUP_NOT_EXIST); diff --git a/redisinsight/api/src/modules/feature/model/feature.ts b/redisinsight/api/src/modules/feature/model/feature.ts index d059087f63..fa475968c9 100644 --- a/redisinsight/api/src/modules/feature/model/feature.ts +++ b/redisinsight/api/src/modules/feature/model/feature.ts @@ -1,9 +1,6 @@ import { Expose } from 'class-transformer'; export class Feature { - @Expose() - version: number; - @Expose() name: string; diff --git a/redisinsight/api/src/modules/feature/model/features-config.ts b/redisinsight/api/src/modules/feature/model/features-config.ts index 6a260f9d6d..0ebae60ffd 100644 --- a/redisinsight/api/src/modules/feature/model/features-config.ts +++ b/redisinsight/api/src/modules/feature/model/features-config.ts @@ -1,9 +1,68 @@ -import { Expose } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, ValidateNested, +} from 'class-validator'; +import { IsMultiNumber, ObjectAsMap } from 'src/common/decorators'; + +export enum FeatureConfigFilterCondition { + Eq = 'eq', + Neq = 'neq', + Gt = 'gt', + Gte = 'gte', + Lt = 'lt', + Lte = 'lte', +} + +export class FeatureConfigFilter { + @Expose() + @IsString() + @IsNotEmpty() + name: string; + + @Expose() + @IsEnum(FeatureConfigFilterCondition) + cond: FeatureConfigFilterCondition; + + @Expose() + value: any; +} + +export class FeatureConfig { + @Expose() + @IsNotEmpty() + @IsBoolean() + flag: boolean; + + @Expose() + @IsArray({ each: true }) + @IsMultiNumber() + perc: number[][]; + + @Expose() + @Type(() => FeatureConfigFilter) + @ValidateNested({ each: true }) + filters: FeatureConfigFilter[]; +} + +export class FeaturesConfigData { + @Expose() + @IsNotEmpty() + @IsNumber() + version: number; + + @Expose() + @ObjectAsMap(FeatureConfig) + @ValidateNested({ each: true }) + features: Map; +} export class FeaturesConfig { @Expose() - controlGroup: number; + @IsNumber() + controlNumber: number; @Expose() - config: any; + @Type(() => FeaturesConfigData) + @ValidateNested() + data: FeaturesConfigData; } 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 index 5083a03a26..c6bf019e21 100644 --- 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 @@ -5,6 +5,7 @@ import { } from 'src/modules/feature/providers/feature-flag/strategies/live-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'; @Injectable() export class FeatureFlagProvider { @@ -12,9 +13,16 @@ export class FeatureFlagProvider { constructor( private readonly featuresConfigService: FeaturesConfigService, + private readonly settingsService: SettingsService, ) { - this.strategies.set('default', new DefaultFlagStrategy(this.featuresConfigService)); - this.strategies.set('liveRecommendations', new LiveRecommendationsFlagStrategy(this.featuresConfigService)); + this.strategies.set('default', new DefaultFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); + this.strategies.set('liveRecommendations', new LiveRecommendationsFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); } getStrategy(name: string): FeatureFlagStrategy { 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..b87bd29aa0 --- /dev/null +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts @@ -0,0 +1,58 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InternalServerErrorException } from '@nestjs/common'; +import { + mockAgreements, + mockAgreementsRepository, mockAppSettings, + mockEncryptionStrategyInstance, mockSettings, + mockSettingsAnalyticsService, mockSettingsRepository, mockSettingsService, + MockType, mockUserId +} from 'src/__mocks__'; +import { UpdateSettingsDto } from 'src/modules/settings/dto/settings.dto'; +import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; +import { AgreementIsNotDefinedException } from 'src/constants'; +import config from 'src/utils/config'; +import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keytar-encryption.strategy'; +import { SettingsAnalytics } from 'src/modules/settings/settings.analytics'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; +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 { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; +import { LiveRecommendationsFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy'; + +const REDIS_SCAN_CONFIG = config.get('redis_scan'); +const WORKBENCH_CONFIG = config.get('workbench'); + +describe('FeatureFlagStrategy', () => { + let service: LiveRecommendationsFlagStrategy; + let settingsService: MockType; + let featuresConfigService: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: SettingsService, + useFactory: mockSettingsService, + }, + FeaturesConfigService, + ], + }).compile(); + + settingsService = module.get(SettingsService); + featuresConfigService = module.get(FeaturesConfigService); + service = new LiveRecommendationsFlagStrategy( + featuresConfigService as unknown as FeaturesConfigService, + settingsService as unknown as SettingsService, + ); + }); + + describe('sync', () => { + it('should sync', async () => { + await service['sync'](); + }); + }); +}); 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 index 4f90230693..0527392394 100644 --- 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 @@ -1,15 +1,73 @@ +import { get, omit } from 'lodash'; import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; +import { SettingsService } from 'src/modules/settings/settings.service'; +import { GetAppSettingsResponse } from 'src/modules/settings/dto/settings.dto'; +import { FeatureConfigFilterCondition } from 'src/modules/feature/model/features-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 { - const controlGroup = await this.featuresConfigService.getControlGroup(); + try { + const { controlNumber } = await this.featuresConfigService.getControlInfo(); - return !!perc.find((range) => controlGroup >= range[0] && controlGroup < range[1]); + return !!perc.find((range) => controlNumber >= range[0] && controlNumber < range[1]); + } catch (e) { + return false; + } + } + + // todo: remove + protected async getAppSettings(): Promise { + try { + return this.settingsService.getAppSettings('1'); + } catch (e) { + return null; + } + } + + protected async getServerState(): Promise { + try { + const appSettings = await this.getAppSettings(); + return { + agreements: appSettings?.agreements, + settings: omit(appSettings, 'agreements'), + }; + } catch (e) { + return null; + } + } + + protected async isInFilter(filters: any[]): Promise { + try { + const state = await this.getServerState(); + + return !!filters.every((filter) => { + const value = get(state, 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) { + return false; + } } } diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts index f45f9b9d0d..f85a85291f 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts @@ -1,10 +1,9 @@ import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; export class LiveRecommendationsFlagStrategy extends FeatureFlagStrategy { - async calculate(conditions: any): Promise { - const isInRange = await this.isInTargetRange(conditions?.perc); + async calculate(featureConfig: any): Promise { + const isInRange = await this.isInTargetRange(featureConfig?.perc); - // todo: add filters - return isInRange ? !!conditions?.flag : !conditions?.flag; + return isInRange && await this.isInFilter(featureConfig?.filters) ? !!featureConfig?.flag : !featureConfig?.flag; } } 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..51a0ba4c0f --- /dev/null +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockAgreements, + mockAgreementsEntity, mockFeaturesConfig, mockFeaturesConfigEntity, mockFeaturesConfigId, + mockRepository, + MockType, mockUserId +} from 'src/__mocks__'; +import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity'; +import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; +import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; + +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(true); // no meter of response + repository.save.mockResolvedValue(mockFeaturesConfigEntity); + }); + + describe('getOrCreate', () => { + it('ttt', async () => { + console.log('___ m entity', require('util').inspect(mockFeaturesConfigEntity, { depth: null })) + console.log('___ m model', require('util').inspect(mockFeaturesConfig, { depth: null })) + }); + // 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({ + // ...mockAgreements, + // version: undefined, + // data: undefined, + // }); + // }); + }); + + // describe('update', () => { + // it('should update agreements', async () => { + // const result = await service.update(mockUserId, mockAgreements); + // + // expect(result).toEqual(mockAgreements); + // expect(repository.update).toHaveBeenCalledWith({}, { + // ...mockAgreementsEntity, + // }); + // }); + // }); +}); 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 index 2b9af8bffd..ed7b56bfe9 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -50,14 +50,14 @@ export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { entity = await this.repository.save(plainToClass(FeaturesConfigEntity, { id: this.id, - config: defaultConfig, - controlGroup: this.generateControlGroup(), + data: defaultConfig, + controlNumber: this.generateControlGroup(), })); } const model = classToClass(FeaturesConfig, entity); - if (model?.config?.version < defaultConfig?.version) { + if (model?.data?.version < defaultConfig?.version) { return this.update(defaultConfig); } diff --git a/redisinsight/api/src/modules/server/dto/server.dto.ts b/redisinsight/api/src/modules/server/dto/server.dto.ts index ddebe3f641..ea68a68ac3 100644 --- a/redisinsight/api/src/modules/server/dto/server.dto.ts +++ b/redisinsight/api/src/modules/server/dto/server.dto.ts @@ -56,8 +56,14 @@ export class GetServerInfoResponse { sessionId: number; @ApiProperty({ - description: 'Control group number for A/B testing', + description: 'Control number for A/B testing', type: Number, }) - controlGroup: number; + controlNumber: number; + + @ApiProperty({ + description: 'Control group (bucket)', + type: String, + }) + controlGroup: string; } diff --git a/redisinsight/api/src/modules/server/server.service.ts b/redisinsight/api/src/modules/server/server.service.ts index 326e2616a4..0b9c3c60f1 100644 --- a/redisinsight/api/src/modules/server/server.service.ts +++ b/redisinsight/api/src/modules/server/server.service.ts @@ -52,7 +52,7 @@ export class ServerService implements OnApplicationBootstrap { anonymousId: server.id, sessionId: this.sessionId, appType: this.getAppType(SERVER_CONFIG.buildType), - controlGroup: await this.featuresConfigService.getControlGroup(), + ...(await this.featuresConfigService.getControlInfo()), }); // do not track start events for non-electron builds @@ -88,7 +88,7 @@ export class ServerService implements OnApplicationBootstrap { appType: this.getAppType(SERVER_CONFIG.buildType), encryptionStrategies: await this.encryptionService.getAvailableEncryptionStrategies(), fixedDatabaseId: REDIS_STACK_CONFIG?.id, - controlGroup: await this.featuresConfigService.getControlGroup(), + ...(await this.featuresConfigService.getControlInfo()), }; this.logger.log('Succeed to get server info.'); return result; 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); From 60272e0ea7d42f260537f7b73e3eae94bd71495b Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Mon, 15 May 2023 10:56:13 +0200 Subject: [PATCH 07/55] fix tests --- redisinsight/ui/src/utils/test-utils.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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({}), }) ) From 23eb2e29d4f721199311c6393c7a1263fba2c41e Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 15 May 2023 11:59:40 +0300 Subject: [PATCH 08/55] #RI-4489 fix transformer --- redisinsight/api/src/__mocks__/feature.ts | 3 ++- .../api/src/common/decorators/object-as-map.decorator.ts | 4 ++-- .../local.features-config.repository.spec.ts | 9 ++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index 8b7b21de1b..cd11b8efe4 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -1,6 +1,7 @@ import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import { FeaturesConfig, FeaturesConfigData } from 'src/modules/feature/model/features-config'; import { plainToClass } from 'class-transformer'; +import { classToClass } from 'src/utils'; export const mockFeaturesConfigId = '1'; export const mockControlNumber = 7.68; @@ -45,8 +46,8 @@ export const mockFeaturesConfig = Object.assign(new FeaturesConfig(), { }); export const mockFeaturesConfigEntity = Object.assign(new FeaturesConfigEntity(), { + ...classToClass(FeaturesConfigEntity, mockFeaturesConfig), id: mockFeaturesConfigId, - data: JSON.stringify(mockFeaturesConfig), }); export const mockFeaturesConfigRepository = jest.fn(() => ({ diff --git a/redisinsight/api/src/common/decorators/object-as-map.decorator.ts b/redisinsight/api/src/common/decorators/object-as-map.decorator.ts index 2e4281ea9b..86ae6ecce4 100644 --- a/redisinsight/api/src/common/decorators/object-as-map.decorator.ts +++ b/redisinsight/api/src/common/decorators/object-as-map.decorator.ts @@ -18,11 +18,11 @@ export function ObjectAsMap(targetClass: ClassType) { return result; } }, { toClassOnly: true }), - Transform((object): object => { + Transform((map): object => { try { const result = {}; - forEach(object, (value, key) => { + forEach(Array.from(map), ([key, value]) => { result[key] = classToPlain(value); }); 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 index 51a0ba4c0f..cfc42d2d8a 100644 --- 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 @@ -3,13 +3,15 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { mockAgreements, - mockAgreementsEntity, mockFeaturesConfig, mockFeaturesConfigEntity, mockFeaturesConfigId, + mockAgreementsEntity, mockFeaturesConfig, mockFeaturesConfigEntity, mockFeaturesConfigId, mockFeaturesConfigJson, mockRepository, MockType, mockUserId } from 'src/__mocks__'; import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity'; import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; +import { classToPlain, plainToClass } from 'class-transformer'; +import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; describe('LocalFeaturesConfigRepository', () => { let service: LocalFeaturesConfigRepository; @@ -38,8 +40,9 @@ describe('LocalFeaturesConfigRepository', () => { describe('getOrCreate', () => { it('ttt', async () => { - console.log('___ m entity', require('util').inspect(mockFeaturesConfigEntity, { depth: null })) - console.log('___ m model', require('util').inspect(mockFeaturesConfig, { depth: null })) + console.log('___ mockFeaturesConfigJson', require('util').inspect(mockFeaturesConfigJson, { depth: null })) + console.log('___ mockFeaturesConfig', require('util').inspect(mockFeaturesConfig, { depth: null })) + console.log('___ mockFeaturesConfigEntity', require('util').inspect(mockFeaturesConfigEntity, { depth: null })) }); // it('should return existing config', async () => { // const result = await service.getOrCreate(); From 481f3577745b618f16ce1da5256a949bb23331d8 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 15 May 2023 15:26:13 +0300 Subject: [PATCH 09/55] #RI-4489 fix conf update --- .../feature/providers/feature-flag/feature-flag.provider.ts | 2 +- .../feature/repositories/local.features-config.repository.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index c6bf019e21..14af16d6c2 100644 --- 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 @@ -5,7 +5,7 @@ import { } from 'src/modules/feature/providers/feature-flag/strategies/live-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 { SettingsService } from 'src/modules/settings/settings.service'; @Injectable() export class FeatureFlagProvider { 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 index ed7b56bfe9..949937b8b7 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -67,10 +67,10 @@ export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { /** * @inheritDoc */ - async update(config: any): Promise { + async update(data: any): Promise { const entity = await this.repository.update( { id: this.id }, - plainToClass(FeaturesConfigEntity, { config }), + plainToClass(FeaturesConfigEntity, { data }), ); return classToClass(FeaturesConfig, entity); From f62347b06dd3c608e216919c830aa3514cbcb709 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 15 May 2023 18:59:05 +0200 Subject: [PATCH 10/55] add tests for hide recommendations --- tests/e2e/.desktop.env | 2 ++ tests/e2e/.env | 1 + tests/e2e/helpers/conf.ts | 2 ++ tests/e2e/helpers/insights.ts | 21 +++++++++++ tests/e2e/helpers/notifications.ts | 5 +-- tests/e2e/pageObjects/settings-page.ts | 12 ++++++- .../enablement-area-autoupdate.e2e.ts | 4 +-- .../promo-button-autoupdate.e2e.ts | 4 +-- .../insights/live-recommendations.e2e.ts | 35 ++++++++++++++++++- 9 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/helpers/insights.ts diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 256c0a563b..2e21077c95 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -26,3 +26,5 @@ 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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json diff --git a/tests/e2e/.env b/tests/e2e/.env index d0ccc363c5..6a0ed59d4e 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -4,3 +4,4 @@ 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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json 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/insights.ts b/tests/e2e/helpers/insights.ts new file mode 100644 index 0000000000..032957500c --- /dev/null +++ b/tests/e2e/helpers/insights.ts @@ -0,0 +1,21 @@ +import { workingDirectory} from '../helpers/conf'; + +const dbPath = `${workingDirectory}/redisinsight.db`; + +const sqlite3 = require('sqlite3').verbose(); + +/** + * Update controlNumber parameter into local DB + * @param controlNumber Control Number of user + */ +export function updateControlNumberInDB(controlNumber: Number): void { + const db = new sqlite3.Database(dbPath); + const query = `UPDATE features_config SET controlNumber = ${controlNumber}`; + + db.run(query, function(err: { message: string }) { + if (err) { + return console.log(`error during changing controlNumber: ${err.message}`); + } + }); + db.close(); +} diff --git a/tests/e2e/helpers/notifications.ts b/tests/e2e/helpers/notifications.ts index 94952c19ee..05afaa0a50 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/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/pageObjects/settings-page.ts b/tests/e2e/pageObjects/settings-page.ts index 99dc61d407..78c25fa6a4 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.accordionAppearance); @@ -97,4 +97,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.getEulaSwitcherValue()) { + await t.click(this.switchAnalyticsOption); + } + } } 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/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index cd642bedc6..b0d8601d74 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -1,8 +1,9 @@ -import { BrowserPage, MemoryEfficiencyPage, MyRedisDatabasePage, WorkbenchPage } from '../../../pageObjects'; +import { BrowserPage, MemoryEfficiencyPage, MyRedisDatabasePage, SettingsPage, 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 +11,7 @@ import { import { Common } from '../../../helpers/common'; import { Telemetry } from '../../../helpers/telemetry'; import { RecommendationsActions } from '../../../common-actions/recommendations-actions'; +import { updateControlNumberInDB } from '../../../helpers/insights'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -17,6 +19,7 @@ const workbenchPage = new WorkbenchPage(); const telemetry = new Telemetry(); const memoryEfficiencyPage = new MemoryEfficiencyPage(); const recommendationsActions = new RecommendationsActions(); +const settingsPage = new SettingsPage(); const databasesForAdding = [ { host: ossStandaloneV5Config.host, port: ossStandaloneV5Config.port, databaseName: ossStandaloneV5Config.databaseName }, @@ -47,6 +50,36 @@ fixture `Live Recommendations` // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); +test.only + .before(async t => { + // await acceptLicenseTerms(); + // await addNewStandaloneDatabaseApi(ossStandaloneV5Config); + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await updateControlNumberInDB(19.2); + await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await settingsPage.changeAnalyticsSwitcher(false); + await settingsPage.changeAnalyticsSwitcher(true); + }).after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + })('Verify that Insights panel displayed if the local config file has it enabled for new user', async t => { + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number according to config'); + await browserPage.InsightsPanel.toggleInsightsPanel(true); + await t.expect(await browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); + }); +test.only + .before(async t => { + // await acceptLicenseTerms(); + // await addNewStandaloneDatabaseApi(ossStandaloneV5Config); + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await updateControlNumberInDB(30.1); + await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await settingsPage.changeAnalyticsSwitcher(false); + await settingsPage.changeAnalyticsSwitcher(true); + }).after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + })('Verify that Insights panel not displayed if the local config file has it disabled', async t => { + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of config'); + }); test .before(async() => { // Add new databases using API From 5a77fb447709467761bb9b7a0eea1aeeda8d66e8 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 15 May 2023 21:40:03 +0300 Subject: [PATCH 11/55] #RI-4489 add migrations + start UTests --- .../api/migration/1684175820824-feature.ts | 16 ++++++ redisinsight/api/migration/index.ts | 2 + redisinsight/api/src/__mocks__/feature.ts | 36 ++++++------ .../feature/features-config.service.ts | 8 ++- .../feature/model/features-config.spec.ts | 57 +++++++++++++++++++ .../local.features-config.repository.spec.ts | 39 +++++++++++-- .../local.features-config.repository.ts | 15 ++--- .../modules/server/entities/server.entity.ts | 8 +-- 8 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 redisinsight/api/migration/1684175820824-feature.ts create mode 100644 redisinsight/api/src/modules/feature/model/features-config.spec.ts diff --git a/redisinsight/api/migration/1684175820824-feature.ts b/redisinsight/api/migration/1684175820824-feature.ts new file mode 100644 index 0000000000..c4cd8c3a6e --- /dev/null +++ b/redisinsight/api/migration/1684175820824-feature.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class feature1684175820824 implements MigrationInterface { + name = 'feature1684175820824' + + 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" integer, "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..96a21072c7 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 { feature1684175820824 } from './1684175820824-feature'; export default [ initialMigration1614164490968, @@ -66,4 +67,5 @@ export default [ customTutorials1677135091633, databaseRecommendations1681900503586, databaseRecommendationParams1683006064293, + feature1684175820824, ]; diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index cd11b8efe4..16a78035b1 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -1,13 +1,18 @@ import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; -import { FeaturesConfig, FeaturesConfigData } from 'src/modules/feature/model/features-config'; -import { plainToClass } from 'class-transformer'; +import { + FeatureConfig, + FeatureConfigFilter, + FeaturesConfig, + FeaturesConfigData, +} from 'src/modules/feature/model/features-config'; import { classToClass } from 'src/utils'; export const mockFeaturesConfigId = '1'; +export const mockFeaturesConfigVersion = 1.111; export const mockControlNumber = 7.68; export const mockFeaturesConfigJson = { - version: 1, + version: mockFeaturesConfigVersion, features: { liveRecommendations: { perc: [[0, 10]], @@ -23,21 +28,16 @@ export const mockFeaturesConfigJson = { }, }; -export const mockFeaturesConfigData = plainToClass(FeaturesConfigData, { - version: mockFeaturesConfigJson.version, - features: { - liveRecommendations: { - perc: [[0, 10]], - flag: true, +export const mockFeaturesConfigData = Object.assign(new FeaturesConfigData(), { + ...mockFeaturesConfigJson, + features: new Map(Object.entries({ + liveRecommendations: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features.liveRecommendations, filters: [ - { - name: 'agreements.analytics', - value: true, - cond: 'eq', - }, + Object.assign(new FeatureConfigFilter(), { ...mockFeaturesConfigJson.features.liveRecommendations.filters[0] }), ], - }, - }, + }), + })), }); export const mockFeaturesConfig = Object.assign(new FeaturesConfig(), { @@ -51,8 +51,8 @@ export const mockFeaturesConfigEntity = Object.assign(new FeaturesConfigEntity() }); export const mockFeaturesConfigRepository = jest.fn(() => ({ - getOrCreate: jest.fn(), - update: jest.fn(), + getOrCreate: jest.fn().mockResolvedValue(mockFeaturesConfig), + update: jest.fn().mockResolvedValue(mockFeaturesConfig), })); export const mockFeaturesConfigService = () => ({ diff --git a/redisinsight/api/src/modules/feature/features-config.service.ts b/redisinsight/api/src/modules/feature/features-config.service.ts index 128d0d680e..82a5cf041d 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -8,6 +8,8 @@ import ERROR_MESSAGES from 'src/constants/error-messages'; import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; import { 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'; const FEATURES_CONFIG = config.get('features_config'); @@ -41,9 +43,8 @@ export class FeaturesConfigService { return JSON.parse(data); } catch (error) { this.logger.error('Unable to fetch remote config', error); + throw error; } - - return null; } /** @@ -54,7 +55,8 @@ export class FeaturesConfigService { this.logger.log('Trying to sync features config...'); const featuresConfig = await this.repository.getOrCreate(); - const newConfig = await this.fetchRemoteConfig(); + // todo: update from default config with version > than current + const newConfig = plainToClass(FeaturesConfigData, await this.fetchRemoteConfig()); await this.validator.validateOrReject(newConfig); 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..5d34decbd2 --- /dev/null +++ b/redisinsight/api/src/modules/feature/model/features-config.spec.ts @@ -0,0 +1,57 @@ +import { + mockFeaturesConfig, mockFeaturesConfigEntity, + mockFeaturesConfigJson, +} 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: {}, + 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/repositories/local.features-config.repository.spec.ts b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.spec.ts index cfc42d2d8a..6c96837be6 100644 --- 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 @@ -12,7 +12,6 @@ import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/ import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import { classToPlain, plainToClass } from 'class-transformer'; import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; - describe('LocalFeaturesConfigRepository', () => { let service: LocalFeaturesConfigRepository; let repository: MockType>; @@ -38,11 +37,43 @@ describe('LocalFeaturesConfigRepository', () => { 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('ttt', async () => { - console.log('___ mockFeaturesConfigJson', require('util').inspect(mockFeaturesConfigJson, { depth: null })) - console.log('___ mockFeaturesConfig', require('util').inspect(mockFeaturesConfig, { depth: null })) - console.log('___ mockFeaturesConfigEntity', require('util').inspect(mockFeaturesConfigEntity, { depth: null })) }); // it('should return existing config', async () => { // const result = await service.getOrCreate(); 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 index 949937b8b7..75788c853e 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -24,17 +24,14 @@ export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { } /** - * Generate control group which should never be updated + * Generate control number which should never be updated * @private */ - private generateControlGroup(): number { - this.logger.log('Getting control group'); + private generateControlNumber(): number { + const controlNumber = Number((parseInt((Math.random() * 10_000).toString(), 10) / 100).toFixed(2)); + this.logger.log('Control number is generated', controlNumber); - const controlGroup = Number((Math.random() * 100).toFixed(2)); - - this.logger.log('Control group generated', controlGroup); - - return controlGroup; + return controlNumber; } /** @@ -51,7 +48,7 @@ export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { entity = await this.repository.save(plainToClass(FeaturesConfigEntity, { id: this.id, data: defaultConfig, - controlNumber: this.generateControlGroup(), + controlNumber: this.generateControlNumber(), })); } diff --git a/redisinsight/api/src/modules/server/entities/server.entity.ts b/redisinsight/api/src/modules/server/entities/server.entity.ts index ab92e82ed9..26b7492c78 100644 --- a/redisinsight/api/src/modules/server/entities/server.entity.ts +++ b/redisinsight/api/src/modules/server/entities/server.entity.ts @@ -1,6 +1,4 @@ -import { - Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; import { Expose } from 'class-transformer'; @Entity('server') @@ -12,8 +10,4 @@ export class ServerEntity { @CreateDateColumn({ type: 'datetime', nullable: false }) @Expose() createDateTime: string; - - @Expose() - @Column({ nullable: true }) - controlGroup: number; } From ceb2482f47481f5ce02fae54c2df3ed12ac46bbb Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 15 May 2023 22:46:25 +0300 Subject: [PATCH 12/55] #RI-4489 UTests + endpoint for manually sync --- .../src/modules/feature/feature.controller.ts | 12 +++- .../feature/features-config.service.ts | 2 +- .../local.features-config.repository.spec.ts | 70 +++++++++++-------- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/redisinsight/api/src/modules/feature/feature.controller.ts b/redisinsight/api/src/modules/feature/feature.controller.ts index 84d71f2346..f9a662a629 100644 --- a/redisinsight/api/src/modules/feature/feature.controller.ts +++ b/redisinsight/api/src/modules/feature/feature.controller.ts @@ -1,12 +1,13 @@ import { Controller, - Get, + Get, HttpCode, Post, UsePipes, - ValidationPipe, + 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') @@ -14,6 +15,7 @@ import { FeatureService } from 'src/modules/feature/feature.service'; export class FeatureController { constructor( private featureService: FeatureService, + private featuresConfigService: FeaturesConfigService, ) {} @Get('') @@ -30,4 +32,10 @@ export class FeatureController { 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/features-config.service.ts b/redisinsight/api/src/modules/feature/features-config.service.ts index 82a5cf041d..c8a3433385 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -50,7 +50,7 @@ export class FeaturesConfigService { /** * Get latest config from remote and save it in the local database */ - private async sync() { + public async sync() { try { this.logger.log('Trying to sync features config...'); 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 index 6c96837be6..14dcf483d8 100644 --- 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 @@ -3,15 +3,23 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { mockAgreements, - mockAgreementsEntity, mockFeaturesConfig, mockFeaturesConfigEntity, mockFeaturesConfigId, mockFeaturesConfigJson, + mockAgreementsEntity, + mockFeaturesConfig, + mockFeaturesConfigData, + mockFeaturesConfigEntity, + mockFeaturesConfigId, + mockFeaturesConfigJson, mockRepository, - MockType, mockUserId + MockType, + mockUserId } from 'src/__mocks__'; import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity'; import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import { classToPlain, plainToClass } from 'class-transformer'; import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; +import * as defaultConfig from '../../../../config/features-config.json'; + describe('LocalFeaturesConfigRepository', () => { let service: LocalFeaturesConfigRepository; let repository: MockType>; @@ -33,7 +41,7 @@ describe('LocalFeaturesConfigRepository', () => { service = await module.get(LocalFeaturesConfigRepository); repository.findOneBy.mockResolvedValue(mockFeaturesConfigEntity); - repository.update.mockResolvedValue(true); // no meter of response + repository.update.mockResolvedValue(mockFeaturesConfigEntity); repository.save.mockResolvedValue(mockFeaturesConfigEntity); }); @@ -73,34 +81,34 @@ describe('LocalFeaturesConfigRepository', () => { }); describe('getOrCreate', () => { - it('ttt', async () => { + it('should return existing config', async () => { + const result = await service.getOrCreate(); + + expect(result).toEqual(mockFeaturesConfig); }); - // 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({ - // ...mockAgreements, - // version: undefined, - // data: undefined, - // }); - // }); - }); + it('should update existing config with newest default', async () => { + repository.findOneBy.mockResolvedValueOnce(plainToClass(FeaturesConfigEntity, { + ...mockFeaturesConfig, + data: { + ...mockFeaturesConfigData, + version: defaultConfig.version - 0.1, + }, + })); + + const result = await service.getOrCreate(); - // describe('update', () => { - // it('should update agreements', async () => { - // const result = await service.update(mockUserId, mockAgreements); - // - // expect(result).toEqual(mockAgreements); - // expect(repository.update).toHaveBeenCalledWith({}, { - // ...mockAgreementsEntity, - // }); - // }); - // }); + expect(result).toEqual(mockFeaturesConfig); + expect(repository.update).toHaveBeenCalledWith( + { id: service['id'] }, + plainToClass(FeaturesConfigEntity, { data: defaultConfig }), + ); + }); + it('should create new config', async () => { + repository.findOneBy.mockResolvedValueOnce(null); + + const result = await service.getOrCreate(); + + expect(result).toEqual(mockFeaturesConfig); + }); + }); }); From 19ecdd74c3f039aef2d97cc251aca78104cb0caa Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 15 May 2023 22:21:22 +0200 Subject: [PATCH 13/55] fix for changeAnalyticsSwitcher --- tests/e2e/pageObjects/settings-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/pageObjects/settings-page.ts b/tests/e2e/pageObjects/settings-page.ts index 78c25fa6a4..08d0e401bb 100644 --- a/tests/e2e/pageObjects/settings-page.ts +++ b/tests/e2e/pageObjects/settings-page.ts @@ -103,7 +103,7 @@ export class SettingsPage extends BasePage { */ async changeAnalyticsSwitcher(toValue: boolean): Promise { await t.click(this.accordionPrivacySettings); - if (toValue !== await this.getEulaSwitcherValue()) { + if (toValue !== await this.getAnalyticsSwitcherValue()) { await t.click(this.switchAnalyticsOption); } } From dae96f8f4c476cf57a19a0f71990920348dc205a Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 15 May 2023 22:34:13 +0200 Subject: [PATCH 14/55] fix --- tests/e2e/helpers/api/api-keys.ts | 2 +- .../e2e/tests/regression/insights/live-recommendations.e2e.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index b0d8601d74..4659026c98 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -62,6 +62,8 @@ test.only }).after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that Insights panel displayed if the local config file has it enabled for new user', async t => { + // Open Browser page + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number according to config'); await browserPage.InsightsPanel.toggleInsightsPanel(true); await t.expect(await browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); @@ -78,6 +80,8 @@ test.only }).after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that Insights panel not displayed if the local config file has it disabled', async t => { + // Open Browser page + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of config'); }); test From e456b0312b4f19a2784fd5aeca45577c1fed2c70 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 11:50:42 +0200 Subject: [PATCH 15/55] add changes --- tests/e2e/.env | 4 +- tests/e2e/helpers/api/api-info.ts | 29 +++++ tests/e2e/helpers/insights.ts | 12 +++ .../remote/desktop/features-config.json | 21 ++++ .../e2e/test-data/remote/features-config.json | 21 ++++ .../insights/live-recommendations.e2e.ts | 101 +++++++++++++----- 6 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/helpers/api/api-info.ts create mode 100644 tests/e2e/test-data/remote/desktop/features-config.json create mode 100644 tests/e2e/test-data/remote/features-config.json diff --git a/tests/e2e/.env b/tests/e2e/.env index 6a0ed59d4e..37494d5aae 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -4,4 +4,6 @@ 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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json +# RI_FEATURES_CONFIG_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json +RI_FEATURES_CONFIG_URL=http://localhost:3000/remote/features-config.json +RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts new file mode 100644 index 0000000000..691e074482 --- /dev/null +++ b/tests/e2e/helpers/api/api-info.ts @@ -0,0 +1,29 @@ +import { t } from 'testcafe'; +import * as request from 'supertest'; +import { Common } from '../common'; +import * as express from 'express'; +import * as fs from 'fs-extra'; + +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}`); +} + +/** + * Initiate remote server to fetch various static data like notificaitons or features configs + */ +export const initRemoteServer = async () => { + const path = './test-data/remote'; + await fs.ensureDir(path); + + const app = express(); + app.use('/remote', express.static(path)); + // Start the server + await app.listen(3000); +} diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 032957500c..f6751b8d68 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,3 +1,4 @@ +import * as fs from 'fs'; import { workingDirectory} from '../helpers/conf'; const dbPath = `${workingDirectory}/redisinsight.db`; @@ -19,3 +20,14 @@ export function updateControlNumberInDB(controlNumber: Number): void { }); db.close(); } + +/** + * Update version into local features-config file + * @param filePath Path to config file + * @param version New version for features-config + */ +export function updateFeaturesConfigVersion(filePath: string, newVersion: Number): void { + const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + jsonData.version = newVersion; + fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2)); +} diff --git a/tests/e2e/test-data/remote/desktop/features-config.json b/tests/e2e/test-data/remote/desktop/features-config.json new file mode 100644 index 0000000000..8d5043fa3b --- /dev/null +++ b/tests/e2e/test-data/remote/desktop/features-config.json @@ -0,0 +1,21 @@ +{ + "version": 5, + "features": { + "liveRecommendations": { + "flag": true, + "perc": [ + [ + 0, + 100 + ] + ], + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/test-data/remote/features-config.json b/tests/e2e/test-data/remote/features-config.json new file mode 100644 index 0000000000..7e7ab02198 --- /dev/null +++ b/tests/e2e/test-data/remote/features-config.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "features": { + "liveRecommendations": { + "flag": true, + "perc": [ + [ + 0, + 20 + ] + ], + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 4659026c98..9b1bfa9018 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -1,17 +1,18 @@ import { BrowserPage, MemoryEfficiencyPage, MyRedisDatabasePage, SettingsPage, WorkbenchPage } from '../../../pageObjects'; -import { RecommendationIds, rte } from '../../../helpers/constants'; +import { RecommendationIds, rte, env } from '../../../helpers/constants'; import { acceptLicenseTerms, acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; import { - // addNewStandaloneDatabaseApi, + addNewStandaloneDatabaseApi, addNewStandaloneDatabasesApi, deleteStandaloneDatabaseApi, deleteStandaloneDatabasesApi } from '../../../helpers/api/api-database'; +import { syncFeaturesApi } from '../../../helpers/api/api-info'; import { Common } from '../../../helpers/common'; import { Telemetry } from '../../../helpers/telemetry'; import { RecommendationsActions } from '../../../common-actions/recommendations-actions'; -import { updateControlNumberInDB } from '../../../helpers/insights'; +import { updateControlNumberInDB, updateFeaturesConfigVersion } from '../../../helpers/insights'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -35,6 +36,12 @@ const expectedProperties = [ 'provider', 'vote' ]; +const featuresConfigPath = 'C:/Projects/redisinsight-redis/RedisInsight/redisinsight/api/dist/config/features-config.json'; +const updateControlNumber = async(number: Number): Promise => { + updateControlNumberInDB(number); + await syncFeaturesApi(); + await browserPage.reloadPage(); +}; const redisVersionRecom = RecommendationIds.redisVersion; const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; @@ -50,39 +57,79 @@ fixture `Live Recommendations` // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test.only - .before(async t => { - // await acceptLicenseTerms(); - // await addNewStandaloneDatabaseApi(ossStandaloneV5Config); +test + .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await updateControlNumberInDB(19.2); - await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); + await addNewStandaloneDatabaseApi(ossStandaloneConfig); + await updateControlNumber(19.2); + }) + .after(async () => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify that Insights panel displayed if the local config file has it enabled', async t => { + // Verify that config file updated from the GitHub repository if the GitHub file has the latest timestamp + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); + await browserPage.InsightsPanel.toggleInsightsPanel(true); + // Verify that Insights panel displayed if user's controlNumber is in range from config file + await t.expect(browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); + + await browserPage.InsightsPanel.toggleInsightsPanel(false); + // Verify that recommendations displayed for all databases if option enabled + await t.click(browserPage.OverviewPanel.myRedisDbIcon); + await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for the other db connection'); + + // 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'); + + // Turn on telemetry + await t.click(browserPage.NavigationPanel.settingsButton); await settingsPage.changeAnalyticsSwitcher(true); - }).after(async() => { - await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - })('Verify that Insights panel displayed if the local config file has it enabled for new user', async t => { - // Open Browser page await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number according to config'); - await browserPage.InsightsPanel.toggleInsightsPanel(true); - await t.expect(await browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed after enabling analytics'); + + // Verify that Insights panel not displayed if the local config file has it disabled + await updateControlNumber(30.1); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); }); -test.only +test .before(async t => { - // await acceptLicenseTerms(); - // await addNewStandaloneDatabaseApi(ossStandaloneV5Config); await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await updateControlNumberInDB(30.1); - await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); - await settingsPage.changeAnalyticsSwitcher(false); - await settingsPage.changeAnalyticsSwitcher(true); - }).after(async() => { + // Update local config file to version highter than remote config + updateFeaturesConfigVersion(featuresConfigPath, 5); + await t.wait(5000); + // Update Control Number to be out of range from remote file + await updateControlNumber(45.92); + }) + .after(async () => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + })('Verify that config info is taken from file with higher version', async t => { + // Verify that Insights panel displayed because range was taken from a local file with larger version than the remote file + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); + }); +test + .meta({ env: env.desktop }) + .before(async () => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await updateControlNumber(60.0); + }) + .after(async () => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - })('Verify that Insights panel not displayed if the local config file has it disabled', async t => { - // Open Browser page + })('Verify that Insights panel can be displayed for Electron/WebStack app according to filters', async t => { + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); + + // 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 for user with control number out of config'); + 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 the local config file has it disabled + await updateControlNumber(83.1); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for Electron app when control number out of the config'); }); test .before(async() => { From 58bade1fa82a92f4f98da51627688d4a764b5fc3 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 11:52:08 +0200 Subject: [PATCH 16/55] fix --- .circleci/config.yml | 2 +- tests/e2e/.desktop.env | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ecb4bbfd9c..c334706bdf 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -952,7 +952,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 4 + parallelism: 1 requires: - Build docker image # Workflow for feature, bugfix, main branches diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 2e21077c95..2cda8995fe 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -27,4 +27,5 @@ 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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json +RI_FEATURES_CONFIG_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/electron/features-config.json +RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 From 1d009f8adc5f17192bcb18819ba19dc225452d25 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 18:37:06 +0200 Subject: [PATCH 17/55] create static server container --- tests/e2e/.desktop.env | 2 +- tests/e2e/docker.web.docker-compose.yml | 11 ++++++++++ tests/e2e/helpers/api/api-info.ts | 2 +- .../remote/features-config.json | 0 tests/e2e/static-server.Dockerfile | 9 ++++++++ .../remote/desktop/features-config.json | 21 ------------------- 6 files changed, 22 insertions(+), 23 deletions(-) rename tests/e2e/{test-data => rte}/remote/features-config.json (100%) create mode 100644 tests/e2e/static-server.Dockerfile delete mode 100644 tests/e2e/test-data/remote/desktop/features-config.json diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 2cda8995fe..663980ffa1 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -27,5 +27,5 @@ 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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/electron/features-config.json +RI_FEATURES_CONFIG_URL=http://localhost:3000/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 38cf5d0c14..321ab3297c 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: + - static-server-data:/remote + ports: + - 3000:3000 + e2e: build: context: . @@ -45,3 +54,5 @@ services: - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh +volumes: + static-server-data: diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts index 691e074482..8b40880a12 100644 --- a/tests/e2e/helpers/api/api-info.ts +++ b/tests/e2e/helpers/api/api-info.ts @@ -19,7 +19,7 @@ export async function syncFeaturesApi(): Promise { * Initiate remote server to fetch various static data like notificaitons or features configs */ export const initRemoteServer = async () => { - const path = './test-data/remote'; + const path = '../../test-data/remote'; await fs.ensureDir(path); const app = express(); diff --git a/tests/e2e/test-data/remote/features-config.json b/tests/e2e/rte/remote/features-config.json similarity index 100% rename from tests/e2e/test-data/remote/features-config.json rename to tests/e2e/rte/remote/features-config.json diff --git a/tests/e2e/static-server.Dockerfile b/tests/e2e/static-server.Dockerfile new file mode 100644 index 0000000000..5596f8c62f --- /dev/null +++ b/tests/e2e/static-server.Dockerfile @@ -0,0 +1,9 @@ +FROM node:latest + +USER root + +WORKDIR /usr/src/app + +COPY ./rte/remote /remote + +CMD ["npm", "start"] diff --git a/tests/e2e/test-data/remote/desktop/features-config.json b/tests/e2e/test-data/remote/desktop/features-config.json deleted file mode 100644 index 8d5043fa3b..0000000000 --- a/tests/e2e/test-data/remote/desktop/features-config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": 5, - "features": { - "liveRecommendations": { - "flag": true, - "perc": [ - [ - 0, - 100 - ] - ], - "filters": [ - { - "name": "agreements.analytics", - "value": false, - "cond": "eq" - } - ] - } - } -} \ No newline at end of file From 46b222f3f9dd4e4ac52e62293beff926920204f4 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 18:52:55 +0200 Subject: [PATCH 18/55] fix for dockerfile --- tests/e2e/local.web.docker-compose.yml | 12 ++++++++++++ tests/e2e/static-server.Dockerfile | 8 +------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index 6f41a5d540..8885c43f53 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: + - static-server-data:/remote + ports: + - 3000:3000 + e2e: build: context: . @@ -47,3 +56,6 @@ services: - ./test-data/ssh:/root/ssh ports: - 5000:5000 + +volumes: + static-server-data: diff --git a/tests/e2e/static-server.Dockerfile b/tests/e2e/static-server.Dockerfile index 5596f8c62f..4931bc54d1 100644 --- a/tests/e2e/static-server.Dockerfile +++ b/tests/e2e/static-server.Dockerfile @@ -1,9 +1,3 @@ -FROM node:latest - -USER root - -WORKDIR /usr/src/app +FROM nginx:latest COPY ./rte/remote /remote - -CMD ["npm", "start"] From 18659c1ddc94d3007301c4cd781c8146de4fccc4 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 19:45:03 +0200 Subject: [PATCH 19/55] upd --- tests/e2e/local.web.docker-compose.yml | 5 +++-- tests/e2e/{rte => }/remote/features-config.json | 0 tests/e2e/static-server.Dockerfile | 11 +++++++++-- tests/e2e/static.ts | 8 ++++++++ 4 files changed, 20 insertions(+), 4 deletions(-) rename tests/e2e/{rte => }/remote/features-config.json (100%) create mode 100644 tests/e2e/static.ts diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index 8885c43f53..a23b49a6f7 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -6,9 +6,10 @@ services: context: . dockerfile: static-server.Dockerfile volumes: - - static-server-data:/remote + - ./static.ts:/app/static.ts + - static-server-data:/app/remote ports: - - 3000:3000 + - 5551:5551 e2e: build: diff --git a/tests/e2e/rte/remote/features-config.json b/tests/e2e/remote/features-config.json similarity index 100% rename from tests/e2e/rte/remote/features-config.json rename to tests/e2e/remote/features-config.json diff --git a/tests/e2e/static-server.Dockerfile b/tests/e2e/static-server.Dockerfile index 4931bc54d1..0b589ef2df 100644 --- a/tests/e2e/static-server.Dockerfile +++ b/tests/e2e/static-server.Dockerfile @@ -1,3 +1,10 @@ -FROM nginx:latest +FROM node:latest -COPY ./rte/remote /remote +WORKDIR /app + +COPY package.json . +RUN npm install + +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..d62b7a3442 --- /dev/null +++ b/tests/e2e/static.ts @@ -0,0 +1,8 @@ +import * as express from 'express'; +import * as fs from 'fs-extra'; + +fs.ensureDir('./remote'); + +const app = express(); +app.use('/remote', express.static('./remote')) +app.listen(5551); \ No newline at end of file From 2e42da3a90a21e22f8972bc9d54d293cade3f020 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 20:21:56 +0200 Subject: [PATCH 20/55] updates for static server --- tests/e2e/.desktop.env | 2 +- tests/e2e/.env | 3 +-- tests/e2e/docker.web.docker-compose.yml | 5 +++-- tests/e2e/static-server.Dockerfile | 3 +-- tests/e2e/static.ts | 6 +++--- .../tests/regression/insights/live-recommendations.e2e.ts | 8 ++++---- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 663980ffa1..08fd72e27f 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -27,5 +27,5 @@ 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:3000/remote/features-config.json +RI_FEATURES_CONFIG_URL=http://localhost:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 diff --git a/tests/e2e/.env b/tests/e2e/.env index 37494d5aae..c0c0e38b71 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -4,6 +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=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/features-config.json -RI_FEATURES_CONFIG_URL=http://localhost:3000/remote/features-config.json +RI_FEATURES_CONFIG_URL=http://localhost:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 321ab3297c..951886955e 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -6,9 +6,10 @@ services: context: . dockerfile: static-server.Dockerfile volumes: - - static-server-data:/remote + - ./static.ts:/app/static.ts + - static-server-data:/app/remote ports: - - 3000:3000 + - 5551:5551 e2e: build: diff --git a/tests/e2e/static-server.Dockerfile b/tests/e2e/static-server.Dockerfile index 0b589ef2df..795fff6937 100644 --- a/tests/e2e/static-server.Dockerfile +++ b/tests/e2e/static-server.Dockerfile @@ -3,8 +3,7 @@ FROM node:latest WORKDIR /app COPY package.json . -RUN npm install - +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 index d62b7a3442..b2a133b7e2 100644 --- a/tests/e2e/static.ts +++ b/tests/e2e/static.ts @@ -1,8 +1,8 @@ -import * as express from 'express'; -import * as fs from 'fs-extra'; +const express = require('express'); +const fs = require('fs-extra'); fs.ensureDir('./remote'); const app = express(); app.use('/remote', express.static('./remote')) -app.listen(5551); \ No newline at end of file +app.listen(5551); diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 9b1bfa9018..5ec6abf56a 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -57,7 +57,7 @@ fixture `Live Recommendations` // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test +test.only .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); await addNewStandaloneDatabaseApi(ossStandaloneConfig); @@ -98,9 +98,9 @@ test test .before(async t => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - // Update local config file to version highter than remote config - updateFeaturesConfigVersion(featuresConfigPath, 5); - await t.wait(5000); + // // Update local config file to version highter than remote config + // updateFeaturesConfigVersion(featuresConfigPath, 5); + // await t.wait(5000); // Update Control Number to be out of range from remote file await updateControlNumber(45.92); }) From f2bc70c22d4f9e618cf73a3d139070950650e2f0 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 21:04:09 +0200 Subject: [PATCH 21/55] fix --- tests/e2e/helpers/api/api-info.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts index 8b40880a12..a19cdfeb69 100644 --- a/tests/e2e/helpers/api/api-info.ts +++ b/tests/e2e/helpers/api/api-info.ts @@ -1,8 +1,6 @@ import { t } from 'testcafe'; import * as request from 'supertest'; import { Common } from '../common'; -import * as express from 'express'; -import * as fs from 'fs-extra'; const endpoint = Common.getEndpoint(); @@ -15,15 +13,15 @@ export async function syncFeaturesApi(): Promise { await t.expect(response.status).eql(200, `Synchronization request failed: ${await response.body.message}`); } -/** - * Initiate remote server to fetch various static data like notificaitons or features configs - */ -export const initRemoteServer = async () => { - const path = '../../test-data/remote'; - await fs.ensureDir(path); +// /** +// * Initiate remote server to fetch various static data like notificaitons or features configs +// */ +// export const initRemoteServer = async () => { +// const path = '../../test-data/remote'; +// await fs.ensureDir(path); - const app = express(); - app.use('/remote', express.static(path)); - // Start the server - await app.listen(3000); -} +// const app = express(); +// app.use('/remote', express.static(path)); +// // Start the server +// await app.listen(3000); +// } From 12039087aa473c5cb46cee92f61ba7646b814ef2 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 17 May 2023 21:44:45 +0200 Subject: [PATCH 22/55] upd of env variable --- tests/e2e/.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/.env b/tests/e2e/.env index c0c0e38b71..b86cfd812c 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -4,5 +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://localhost:5551/remote/features-config.json +RI_FEATURES_CONFIG_URL=http://static-server:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 From d04b63e18d8f99fa6a3ccf3ce8e955e96810fd0f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 18 May 2023 12:24:19 +0200 Subject: [PATCH 23/55] upd features config --- tests/e2e/remote/features-config.json | 32 ++++++++++++--------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/tests/e2e/remote/features-config.json b/tests/e2e/remote/features-config.json index 7e7ab02198..8777ad3cfd 100644 --- a/tests/e2e/remote/features-config.json +++ b/tests/e2e/remote/features-config.json @@ -1,21 +1,17 @@ { - "version": 2, + "version": 3, "features": { - "liveRecommendations": { - "flag": true, - "perc": [ - [ - 0, - 20 - ] - ], - "filters": [ - { - "name": "agreements.analytics", - "value": true, - "cond": "eq" - } - ] - } + "liveRecommendations": { + "flag": true, + "perc": [[0, 20]], + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + } + ] + } } -} \ No newline at end of file + } + \ No newline at end of file From 105c91d1eef53c464684c008df55e5067819df06 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 18 May 2023 13:33:28 +0300 Subject: [PATCH 24/55] #RI-4489 filters + UTests + reworks --- redisinsight/api/config/default.ts | 2 +- redisinsight/api/src/__mocks__/common.ts | 1 + redisinsight/api/src/__mocks__/feature.ts | 130 ++++++++++- .../api/src/constants/error-messages.ts | 1 - .../src/modules/feature/feature.service.ts | 4 +- .../feature/features-config.service.spec.ts | 121 +++++++++- .../feature/features-config.service.ts | 74 +++--- .../feature/model/features-config.spec.ts | 12 +- .../modules/feature/model/features-config.ts | 26 ++- .../strategies/feature.flag.strategy.spec.ts | 217 ++++++++++++++++-- .../strategies/feature.flag.strategy.ts | 118 +++++++--- .../live-recommendations.flag.strategy.ts | 2 +- .../local.feature.repository.spec.ts | 84 +++++++ .../repositories/local.feature.repository.ts | 10 +- .../local.features-config.repository.spec.ts | 37 +-- .../local.features-config.repository.ts | 14 +- .../feature-config-filter.transformer.ts | 21 ++ .../src/modules/feature/transformers/index.ts | 1 + redisinsight/api/src/utils/config.ts | 2 +- 19 files changed, 732 insertions(+), 145 deletions(-) create mode 100644 redisinsight/api/src/modules/feature/repositories/local.feature.repository.spec.ts create mode 100644 redisinsight/api/src/modules/feature/transformers/feature-config-filter.transformer.ts create mode 100644 redisinsight/api/src/modules/feature/transformers/index.ts diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index b80b0c03c9..de3ed51a54 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -194,7 +194,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, 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 index 16a78035b1..c6322bbd62 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -1,21 +1,27 @@ import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import { FeatureConfig, - FeatureConfigFilter, + 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 * as defaultConfig from '../../config/features-config.json'; export const mockFeaturesConfigId = '1'; -export const mockFeaturesConfigVersion = 1.111; +export const mockFeaturesConfigVersion = defaultConfig.version + 0.111; export const mockControlNumber = 7.68; +export const mockControlGroup = '7'; export const mockFeaturesConfigJson = { version: mockFeaturesConfigVersion, features: { liveRecommendations: { - perc: [[0, 10]], + perc: [[1.25, 8.45]], flag: true, filters: [ { @@ -28,6 +34,49 @@ export const mockFeaturesConfigJson = { }, }; +export const mockFeaturesConfigJsonComplex = { + ...mockFeaturesConfigJson, + features: { + liveRecommendations: { + ...mockFeaturesConfigJson.features.liveRecommendations, + filters: [ + { + or: [ + { + name: 'env.FORCE_ENABLE_LIVE_RECOMMENDATIONS', + value: 'true', + type: '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({ @@ -40,16 +89,87 @@ export const mockFeaturesConfigData = Object.assign(new FeaturesConfigData(), { })), }); +export const mockFeaturesConfigDataComplex = Object.assign(new FeaturesConfigData(), { + ...mockFeaturesConfigJson, + features: new Map(Object.entries({ + liveRecommendations: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features.liveRecommendations, + filters: [ + Object.assign(new FeatureConfigFilterOr(), { + or: [ + Object.assign(new FeatureConfigFilter(), { + name: 'env.FORCE_ENABLE_LIVE_RECOMMENDATIONS', + value: 'true', + type: '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: 'liveRecommendations', + flag: true, +}); + +export const mockFeatureEntity = Object.assign(new FeatureEntity(), { + id: 'lr-1', + name: 'liveRecommendations', + 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), @@ -57,4 +177,8 @@ export const mockFeaturesConfigRepository = jest.fn(() => ({ export const mockFeaturesConfigService = () => ({ sync: jest.fn(), + getControlInfo: jest.fn().mockResolvedValue({ + controlNumber: mockControlNumber, + controlGroup: mockControlGroup, + }), }); diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 7f90006036..dba176f778 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -64,5 +64,4 @@ export default { APP_SETTINGS_NOT_FOUND: () => 'Could not find application settings.', SERVER_INFO_NOT_FOUND: () => 'Could not find server info.', INCREASE_MINIMUM_LIMIT: (count: string) => `Set MAXSEARCHRESULTS to at least ${count}.`, - CONTROL_GROUP_NOT_EXIST: 'Control group not found.', }; diff --git a/redisinsight/api/src/modules/feature/feature.service.ts b/redisinsight/api/src/modules/feature/feature.service.ts index 11f92c0f36..416fab4391 100644 --- a/redisinsight/api/src/modules/feature/feature.service.ts +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -1,4 +1,4 @@ -import { find, map } from 'lodash'; +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'; @@ -17,6 +17,7 @@ export class FeatureService { private eventEmitter: EventEmitter2, ) {} + // todo: disable recommendations /** * */ @@ -40,6 +41,7 @@ export class FeatureService { return { features }; } + // todo: add api doc + models /** * Recalculate flags for database features based on controlGroup and new conditions */ diff --git a/redisinsight/api/src/modules/feature/features-config.service.spec.ts b/redisinsight/api/src/modules/feature/features-config.service.spec.ts index dd312683c0..0169446e33 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.spec.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.spec.ts @@ -1,40 +1,143 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { InternalServerErrorException } from '@nestjs/common'; +import axios from 'axios'; import { + mockControlGroup, + mockControlNumber, + mockFeaturesConfig, + mockFeaturesConfigJson, mockFeaturesConfigRepository, - MockType + MockType, } from 'src/__mocks__'; -import config from 'src/utils/config'; -import { SettingsService } from 'src/modules/settings/settings.service'; import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; -import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; 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 { FeatureServerEvents } from 'src/modules/feature/constants'; +import * as defaultConfig from '../../../config/features-config.json'; -const REDIS_SCAN_CONFIG = config.get('redis_scan'); -const WORKBENCH_CONFIG = config.get('workbench'); +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; describe('FeaturesConfigService', () => { let service: FeaturesConfigService; let repository: 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, - } + }, ], }).compile(); service = module.get(FeaturesConfigService); + repository = module.get(FeaturesConfigRepository); + 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(mockFeaturesConfigJson); + }); + 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(defaultConfig); + }); + it('should return default config when invalid remote config fetched', async () => { + mockedAxios.get.mockResolvedValue({ + data: JSON.stringify({ + ...mockFeaturesConfigJson, + features: { + liveRecommendations: { + ...mockFeaturesConfigJson.features.liveRecommendations, + flag: 'not boolean flag', + }, + }, + }), + }); + + const result = await service['getNewConfig'](); + + expect(result).toEqual(defaultConfig); + }); + it('should return default config when remote config version less then default', async () => { + mockedAxios.get.mockResolvedValue({ + data: JSON.stringify({ + ...mockFeaturesConfigJson, + version: defaultConfig.version - 0.1, + }), + }); + + const result = await service['getNewConfig'](); + + expect(result).toEqual(defaultConfig); + }); }); describe('sync', () => { - it('should sync', async () => { + 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); + }); + 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); + }); + }); + + 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 index c8a3433385..df65d1257d 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -1,15 +1,15 @@ import axios from 'axios'; import { - Injectable, Logger, NotFoundException, + Injectable, Logger, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import config from 'src/utils/config'; -import ERROR_MESSAGES from 'src/constants/error-messages'; import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; import { 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 * as defaultConfig from '../../../config/features-config.json'; const FEATURES_CONFIG = config.get('features_config'); @@ -25,68 +25,82 @@ export class FeaturesConfigService { ) {} async onApplicationBootstrap() { - await this.sync(); + this.sync().catch(); if (FEATURES_CONFIG.syncInterval > 0) { setInterval(this.sync.bind(this), FEATURES_CONFIG.syncInterval); } } - private async fetchRemoteConfig() { + /** + * Fetch remote new config from remote server + * @private + */ + private async fetchRemoteConfig(): Promise { try { - this.logger.log('Trying to fetch remote features config...'); + this.logger.log('Fetching remote config...'); - const { data } = await axios.get(FEATURES_CONFIG.url, { - responseType: 'text', - transformResponse: [(raw) => raw], - }); + const { data } = await axios.get(FEATURES_CONFIG.url); - return JSON.parse(data); + return data; } catch (error) { this.logger.error('Unable to fetch remote config', error); throw error; } } + private async getNewConfig(): Promise { + let newConfig: any = defaultConfig; + + try { + this.logger.log('Fetching remote config...'); + + const 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 = remoteConfig; + } + } catch (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() { + public async sync(): Promise { try { this.logger.log('Trying to sync features config...'); - const featuresConfig = await this.repository.getOrCreate(); - // todo: update from default config with version > than current - const newConfig = plainToClass(FeaturesConfigData, await this.fetchRemoteConfig()); + const currentConfig = await this.repository.getOrCreate(); + const newConfig = await this.getNewConfig(); - await this.validator.validateOrReject(newConfig); - - if (newConfig?.version > featuresConfig?.data?.version) { + if (newConfig?.version > currentConfig?.data?.version) { await this.repository.update(newConfig); } this.logger.log('Successfully updated stored remote config'); + this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculate); } catch (error) { this.logger.error('Unable to update features config', error); } - - this.eventEmitter.emit(FeatureServerEvents.FeaturesRecalculate); } /** * Get control group field */ public async getControlInfo(): Promise<{ controlNumber: number, controlGroup: string }> { - try { - this.logger.debug('Trying to get controlGroup field'); + this.logger.debug('Trying to get controlGroup field'); - const entity = await (this.repository.getOrCreate()); - return { - controlNumber: entity.controlNumber, - controlGroup: entity.controlNumber.toFixed(0), - }; - } catch (error) { - this.logger.error('Unable to get controlGroup field', error); - throw new NotFoundException(ERROR_MESSAGES.CONTROL_GROUP_NOT_EXIST); - } + 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/features-config.spec.ts b/redisinsight/api/src/modules/feature/model/features-config.spec.ts index 5d34decbd2..33358a08fa 100644 --- a/redisinsight/api/src/modules/feature/model/features-config.spec.ts +++ b/redisinsight/api/src/modules/feature/model/features-config.spec.ts @@ -1,6 +1,6 @@ import { - mockFeaturesConfig, mockFeaturesConfigEntity, - mockFeaturesConfigJson, + mockFeaturesConfig, mockFeaturesConfigComplex, mockFeaturesConfigEntity, mockFeaturesConfigEntityComplex, + mockFeaturesConfigJson, mockFeaturesConfigJsonComplex, } from 'src/__mocks__'; import { classToPlain, plainToClass } from 'class-transformer'; import { FeaturesConfig } from 'src/modules/feature/model/features-config'; @@ -16,6 +16,14 @@ const testCases = [ 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: {}, diff --git a/redisinsight/api/src/modules/feature/model/features-config.ts b/redisinsight/api/src/modules/feature/model/features-config.ts index 0ebae60ffd..5e2c673879 100644 --- a/redisinsight/api/src/modules/feature/model/features-config.ts +++ b/redisinsight/api/src/modules/feature/model/features-config.ts @@ -1,8 +1,9 @@ -import { Expose, Type } from 'class-transformer'; +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', @@ -13,6 +14,8 @@ export enum FeatureConfigFilterCondition { Lte = 'lte', } +export type FeatureConfigFilterType = FeatureConfigFilter | FeatureConfigFilterOr | FeatureConfigFilterAnd; + export class FeatureConfigFilter { @Expose() @IsString() @@ -27,6 +30,22 @@ export class FeatureConfigFilter { 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() @@ -39,9 +58,10 @@ export class FeatureConfig { perc: number[][]; @Expose() - @Type(() => FeatureConfigFilter) + @IsArray() + @Transform(featureConfigFilterTransformer) @ValidateNested({ each: true }) - filters: FeatureConfigFilter[]; + filters: FeatureConfigFilterType[]; } export class FeaturesConfigData { 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 index b87bd29aa0..8fbff91b83 100644 --- 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 @@ -1,32 +1,23 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { InternalServerErrorException } from '@nestjs/common'; import { - mockAgreements, - mockAgreementsRepository, mockAppSettings, - mockEncryptionStrategyInstance, mockSettings, - mockSettingsAnalyticsService, mockSettingsRepository, mockSettingsService, - MockType, mockUserId + mockAppSettings, + mockFeaturesConfig, + mockFeaturesConfigDataComplex, + mockFeaturesConfigService, + mockServerState, + mockSettingsService, + MockType, } from 'src/__mocks__'; -import { UpdateSettingsDto } from 'src/modules/settings/dto/settings.dto'; -import * as AGREEMENTS_SPEC from 'src/constants/agreements-spec.json'; -import { AgreementIsNotDefinedException } from 'src/constants'; -import config from 'src/utils/config'; -import { KeytarEncryptionStrategy } from 'src/modules/encryption/strategies/keytar-encryption.strategy'; -import { SettingsAnalytics } from 'src/modules/settings/settings.analytics'; import { SettingsService } from 'src/modules/settings/settings.service'; -import { AgreementsRepository } from 'src/modules/settings/repositories/agreements.repository'; -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 { FeaturesConfigService } from 'src/modules/feature/features-config.service'; import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; -import { LiveRecommendationsFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy'; - -const REDIS_SCAN_CONFIG = config.get('redis_scan'); -const WORKBENCH_CONFIG = config.get('workbench'); +import { + LiveRecommendationsFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy'; +import { FeatureConfigFilter, FeatureConfigFilterCondition } from 'src/modules/feature/model/features-config'; describe('FeatureFlagStrategy', () => { - let service: LiveRecommendationsFlagStrategy; + let service: FeatureFlagStrategy; let settingsService: MockType; let featuresConfigService: MockType; @@ -38,7 +29,10 @@ describe('FeatureFlagStrategy', () => { provide: SettingsService, useFactory: mockSettingsService, }, - FeaturesConfigService, + { + provide: FeaturesConfigService, + useFactory: mockFeaturesConfigService, + }, ], }).compile(); @@ -48,11 +42,184 @@ describe('FeatureFlagStrategy', () => { 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 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('sync', () => { - it('should sync', async () => { - await service['sync'](); + describe('filter (complex)', () => { + it('should return true for single filter by agreements (eq)', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + agreements: { analytics: true }, + }); + + expect( + await service['filter'](mockFeaturesConfigDataComplex.features.get('liveRecommendations').filters), + ).toEqual(true); }); }); }); 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 index 0527392394..558604a596 100644 --- 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 @@ -1,8 +1,12 @@ -import { get, omit } from 'lodash'; +import { get } from 'lodash'; import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; import { SettingsService } from 'src/modules/settings/settings.service'; -import { GetAppSettingsResponse } from 'src/modules/settings/dto/settings.dto'; -import { FeatureConfigFilterCondition } from 'src/modules/feature/model/features-config'; +import { + FeatureConfigFilter, FeatureConfigFilterAnd, + FeatureConfigFilterCondition, FeatureConfigFilterOr, + FeatureConfigFilterType, +} from 'src/modules/feature/model/features-config'; +import config from 'src/utils/config'; export abstract class FeatureFlagStrategy { constructor( @@ -28,44 +32,106 @@ export abstract class FeatureFlagStrategy { } } - // todo: remove - protected async getAppSettings(): Promise { + protected async getServerState(): Promise { + const state: any = { + config: config.get(), + env: process.env, + agreements: null, + settings: null, + }; + + // determine agreements and settings try { - return this.settingsService.getAppSettings('1'); + const appSettings = await this.settingsService.getAppSettings('1').catch(null); + + state.agreements = appSettings?.agreements; + state.settings = appSettings; } catch (e) { - return null; + // silently ignore error } + return state; } - protected async getServerState(): Promise { + /** + * Check all filters (starting from "AND" since { filters: [] } equal to filters: [{ and: []}]) + * @param filters + * @protected + */ + protected async filter(filters: FeatureConfigFilterType[]): Promise { try { - const appSettings = await this.getAppSettings(); - return { - agreements: appSettings?.agreements, - settings: omit(appSettings, 'agreements'), - }; + const serverState = await this.getServerState(); + return this.checkAndFilters(filters, serverState); } catch (e) { - return null; + return false; } } - protected async isInFilter(filters: any[]): Promise { + /** + * Check all feature filters with recursion + * @param filter + * @param serverState + * @private + */ + private checkFilter(filter: FeatureConfigFilterType, serverState: object): boolean { try { - const state = await this.getServerState(); + if (filter instanceof FeatureConfigFilterAnd) { + return this.checkAndFilters(filter.and, serverState); + } + + if (filter instanceof FeatureConfigFilterOr) { + return this.checkOrFilters(filter.or, serverState); + } - return !!filters.every((filter) => { - const value = get(state, filter?.name); + 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; + 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/live-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts index f85a85291f..55afed8b50 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts @@ -4,6 +4,6 @@ export class LiveRecommendationsFlagStrategy extends FeatureFlagStrategy { async calculate(featureConfig: any): Promise { const isInRange = await this.isInTargetRange(featureConfig?.perc); - return isInRange && await this.isInFilter(featureConfig?.filters) ? !!featureConfig?.flag : !featureConfig?.flag; + return isInRange && await this.filter(featureConfig?.filters) ? !!featureConfig?.flag : !featureConfig?.flag; } } 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 index 15d1012fe9..ff785e6a3c 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts @@ -1,6 +1,4 @@ -import { - Injectable, Logger, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { classToClass } from 'src/utils'; @@ -11,8 +9,6 @@ import { Feature } from '../model/feature'; @Injectable() export class LocalFeatureRepository extends FeatureRepository { - private readonly logger = new Logger('FeatureRepository'); - constructor( @InjectRepository(FeatureEntity) private readonly repository: Repository, @@ -39,12 +35,12 @@ export class LocalFeatureRepository extends FeatureRepository { * @inheritDoc */ async upsert(feature: Feature): Promise { - const entity = await this.repository.upsert(feature, { + await this.repository.upsert(feature, { skipUpdateIfNoValuesChanged: true, conflictPaths: ['name'], }); - return classToClass(Feature, entity); + return this.get(feature.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 index 14dcf483d8..70a91c7cd1 100644 --- 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 @@ -2,22 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { - mockAgreements, - mockAgreementsEntity, mockFeaturesConfig, - mockFeaturesConfigData, mockFeaturesConfigEntity, - mockFeaturesConfigId, - mockFeaturesConfigJson, mockRepository, MockType, - mockUserId } from 'src/__mocks__'; -import { AgreementsEntity } from 'src/modules/settings/entities/agreements.entity'; import { LocalFeaturesConfigRepository } from 'src/modules/feature/repositories/local.features-config.repository'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; -import { classToPlain, plainToClass } from 'class-transformer'; -import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; +import { plainToClass } from 'class-transformer'; import * as defaultConfig from '../../../../config/features-config.json'; describe('LocalFeaturesConfigRepository', () => { @@ -41,7 +33,7 @@ describe('LocalFeaturesConfigRepository', () => { service = await module.get(LocalFeaturesConfigRepository); repository.findOneBy.mockResolvedValue(mockFeaturesConfigEntity); - repository.update.mockResolvedValue(mockFeaturesConfigEntity); + repository.update.mockResolvedValue({ updated: 1 }); repository.save.mockResolvedValue(mockFeaturesConfigEntity); }); @@ -86,29 +78,24 @@ describe('LocalFeaturesConfigRepository', () => { expect(result).toEqual(mockFeaturesConfig); }); - it('should update existing config with newest default', async () => { - repository.findOneBy.mockResolvedValueOnce(plainToClass(FeaturesConfigEntity, { - ...mockFeaturesConfig, - data: { - ...mockFeaturesConfigData, - version: defaultConfig.version - 0.1, - }, - })); + it('should create new config', async () => { + repository.findOneBy.mockResolvedValueOnce(null); const result = await service.getOrCreate(); expect(result).toEqual(mockFeaturesConfig); - expect(repository.update).toHaveBeenCalledWith( - { id: service['id'] }, - plainToClass(FeaturesConfigEntity, { data: defaultConfig }), - ); }); - it('should create new config', async () => { - repository.findOneBy.mockResolvedValueOnce(null); + }); - const result = await service.getOrCreate(); + 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 index 75788c853e..98ce7a6926 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.features-config.repository.ts @@ -52,24 +52,18 @@ export class LocalFeaturesConfigRepository extends FeaturesConfigRepository { })); } - const model = classToClass(FeaturesConfig, entity); - - if (model?.data?.version < defaultConfig?.version) { - return this.update(defaultConfig); - } - - return model; + return classToClass(FeaturesConfig, entity); } /** * @inheritDoc */ async update(data: any): Promise { - const entity = await this.repository.update( + await this.repository.update( { id: this.id }, - plainToClass(FeaturesConfigEntity, { data }), + plainToClass(FeaturesConfigEntity, { data, id: this.id }), ); - return classToClass(FeaturesConfig, entity); + 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/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, From aa46166aa642160ea5427106da7ebd8a682a9e79 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 18 May 2023 13:45:44 +0200 Subject: [PATCH 25/55] upd --- tests/e2e/helpers/insights.ts | 6 ++++++ .../tests/regression/insights/live-recommendations.e2e.ts | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index f6751b8d68..4ee84d9c1d 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -17,6 +17,12 @@ export function updateControlNumberInDB(controlNumber: Number): void { if (err) { return console.log(`error during changing controlNumber: ${err.message}`); } + db.get('SELECT controlName FROM features_config', (err: { message: string }, row: { controlName: string }) => { + if (err) { + return console.log(`error during retrieving controlName: ${err.message}`); + } + console.log('Updated controlName:', row.controlName); + }); }); db.close(); } diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 5ec6abf56a..79e339a7ae 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -36,7 +36,6 @@ const expectedProperties = [ 'provider', 'vote' ]; -const featuresConfigPath = 'C:/Projects/redisinsight-redis/RedisInsight/redisinsight/api/dist/config/features-config.json'; const updateControlNumber = async(number: Number): Promise => { updateControlNumberInDB(number); await syncFeaturesApi(); From 903885dfa9585794dca696ebc1114a24c252f4e8 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 18 May 2023 14:09:11 +0200 Subject: [PATCH 26/55] fix --- tests/e2e/helpers/insights.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 4ee84d9c1d..a37fa1b181 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -17,11 +17,11 @@ export function updateControlNumberInDB(controlNumber: Number): void { if (err) { return console.log(`error during changing controlNumber: ${err.message}`); } - db.get('SELECT controlName FROM features_config', (err: { message: string }, row: { controlName: string }) => { + db.get('SELECT controlNumber FROM features_config', (err: { message: string }, row: { controlNumber: string }) => { if (err) { - return console.log(`error during retrieving controlName: ${err.message}`); + return console.log(`error during retrieving controlNumber: ${err.message}`); } - console.log('Updated controlName:', row.controlName); + console.log('Updated controlNumber:', row.controlNumber); }); }); db.close(); From 5a4b9ff6b7cd6cda8c395a511b3319c9f6394e67 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Thu, 18 May 2023 14:38:25 +0200 Subject: [PATCH 27/55] fix --- tests/e2e/helpers/insights.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index a37fa1b181..1012201479 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -23,6 +23,12 @@ export function updateControlNumberInDB(controlNumber: Number): void { } console.log('Updated controlNumber:', row.controlNumber); }); + db.get('SELECT data FROM features_config', (err: { message: string }, row: { data: string }) => { + if (err) { + return console.log(`error during retrieving data: ${err.message}`); + } + console.log('Updated data:', row.data); + }); }); db.close(); } From ca85515c170827f20a78d0979396f59b757b080b Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 18 May 2023 17:56:30 +0300 Subject: [PATCH 28/55] #RI-4489 fix bind issue + UTests --- redisinsight/api/src/__mocks__/feature.ts | 12 +++++----- .../src/modules/feature/feature.service.ts | 4 ++-- .../strategies/feature.flag.strategy.spec.ts | 24 ++++++++++++++++++- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index c6322bbd62..db7178f567 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -43,9 +43,9 @@ export const mockFeaturesConfigJsonComplex = { { or: [ { - name: 'env.FORCE_ENABLE_LIVE_RECOMMENDATIONS', - value: 'true', - type: 'eq', + name: 'settings.testValue', + value: 'test', + cond: 'eq', }, { and: [ @@ -98,9 +98,9 @@ export const mockFeaturesConfigDataComplex = Object.assign(new FeaturesConfigDat Object.assign(new FeatureConfigFilterOr(), { or: [ Object.assign(new FeatureConfigFilter(), { - name: 'env.FORCE_ENABLE_LIVE_RECOMMENDATIONS', - value: 'true', - type: 'eq', + name: 'settings.testValue', + value: 'test', + cond: 'eq', }), Object.assign(new FeatureConfigFilterAnd(), { and: [ diff --git a/redisinsight/api/src/modules/feature/feature.service.ts b/redisinsight/api/src/modules/feature/feature.service.ts index 416fab4391..94f4cddea4 100644 --- a/redisinsight/api/src/modules/feature/feature.service.ts +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -71,9 +71,9 @@ export class FeatureService { actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.data?.features?.[feature.name]); // delete features - await Promise.all(actions.toDelete.map(this.repository.delete.bind(this))); + await Promise.all(actions.toDelete.map(this.repository.delete.bind(this.repository))); // upsert modified features - await Promise.all(actions.toUpsert.map(this.repository.upsert.bind(this))); + await Promise.all(actions.toUpsert.map(this.repository.upsert.bind(this.repository))); this.logger.log( `Features flags recalculated. Updated: ${actions.toUpsert.length} deleted: ${actions.toDelete.length}`, 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 index 8fbff91b83..a16e25a95a 100644 --- 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 @@ -212,11 +212,33 @@ describe('FeatureFlagStrategy', () => { }); describe('filter (complex)', () => { - it('should return true for single filter by agreements (eq)', async () => { + it('should return true since 2nd or is true', async () => { settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, agreements: { analytics: true }, }); + expect( + await service['filter'](mockFeaturesConfigDataComplex.features.get('liveRecommendations').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('liveRecommendations').filters), + ).toEqual(false); + }); + it('should return true since all 1st or is true', async () => { + settingsService.getAppSettings.mockResolvedValueOnce({ + ...mockAppSettings, + testValue: 'test', + agreements: { analytics: false }, + }); + expect( await service['filter'](mockFeaturesConfigDataComplex.features.get('liveRecommendations').filters), ).toEqual(true); From a81e2885a409cd7bbfd590d976d45bbf96180ab6 Mon Sep 17 00:00:00 2001 From: Artem Date: Sun, 21 May 2023 19:10:42 +0300 Subject: [PATCH 29/55] #RI-4489 UTests (complete) + analytics + rename feature --- .github/CODEOWNERS | 1 + redisinsight/api/config/features-config.json | 9 +- redisinsight/api/src/__mocks__/feature.ts | 59 ++++- .../api/src/constants/telemetry-events.ts | 6 + .../analytics/analytics.service.spec.ts | 22 +- .../modules/analytics/analytics.service.ts | 13 +- .../scanner/recommendations.scanner.spec.ts | 18 ++ .../scanner/recommendations.scanner.ts | 7 + .../src/modules/feature/constants/index.ts | 10 +- .../src/modules/feature/exceptions/index.ts | 1 + ...unable-to-fetch-remote-config.exception.ts | 11 + .../modules/feature/feature.analytics.spec.ts | 238 ++++++++++++++++++ .../src/modules/feature/feature.analytics.ts | 101 ++++++++ .../api/src/modules/feature/feature.module.ts | 2 + .../modules/feature/feature.service.spec.ts | 125 +++++++++ .../src/modules/feature/feature.service.ts | 42 +++- .../feature/features-config.service.spec.ts | 51 +++- .../feature/features-config.service.ts | 51 +++- .../feature-flag.provider.spec.ts | 66 +++++ .../feature-flag/feature-flag.provider.ts | 7 +- .../strategies/feature.flag.strategy.spec.ts | 204 +++++++++++++-- ...insights-recommendations.flag.strategy.ts} | 2 +- .../repositories/local.feature.repository.ts | 1 - .../src/modules/server/server.service.spec.ts | 23 +- .../modules/settings/settings.service.spec.ts | 15 +- 25 files changed, 1005 insertions(+), 80 deletions(-) create mode 100644 redisinsight/api/src/modules/feature/exceptions/index.ts create mode 100644 redisinsight/api/src/modules/feature/exceptions/unable-to-fetch-remote-config.exception.ts create mode 100644 redisinsight/api/src/modules/feature/feature.analytics.spec.ts create mode 100644 redisinsight/api/src/modules/feature/feature.analytics.ts create mode 100644 redisinsight/api/src/modules/feature/feature.service.spec.ts create mode 100644 redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts rename redisinsight/api/src/modules/feature/providers/feature-flag/strategies/{live-recommendations.flag.strategy.ts => insights-recommendations.flag.strategy.ts} (82%) 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/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index d1700e47b4..17fb431396 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,14 +1,19 @@ { "version": 1, "features": { - "liveRecommendations": { + "insightsRecommendations": { "flag": true, - "perc": [[0, 100]], + "perc": [], "filters": [ { "name": "agreements.analytics", "value": true, "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" } ] } diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index db7178f567..2d30232a30 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -10,6 +10,7 @@ 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'; @@ -20,7 +21,7 @@ export const mockControlGroup = '7'; export const mockFeaturesConfigJson = { version: mockFeaturesConfigVersion, features: { - liveRecommendations: { + [KnownFeatures.InsightsRecommendations]: { perc: [[1.25, 8.45]], flag: true, filters: [ @@ -37,8 +38,8 @@ export const mockFeaturesConfigJson = { export const mockFeaturesConfigJsonComplex = { ...mockFeaturesConfigJson, features: { - liveRecommendations: { - ...mockFeaturesConfigJson.features.liveRecommendations, + [KnownFeatures.InsightsRecommendations]: { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], filters: [ { or: [ @@ -80,10 +81,12 @@ export const mockFeaturesConfigJsonComplex = { export const mockFeaturesConfigData = Object.assign(new FeaturesConfigData(), { ...mockFeaturesConfigJson, features: new Map(Object.entries({ - liveRecommendations: Object.assign(new FeatureConfig(), { - ...mockFeaturesConfigJson.features.liveRecommendations, + [KnownFeatures.InsightsRecommendations]: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], filters: [ - Object.assign(new FeatureConfigFilter(), { ...mockFeaturesConfigJson.features.liveRecommendations.filters[0] }), + Object.assign(new FeatureConfigFilter(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations].filters[0], + }), ], }), })), @@ -92,8 +95,8 @@ export const mockFeaturesConfigData = Object.assign(new FeaturesConfigData(), { export const mockFeaturesConfigDataComplex = Object.assign(new FeaturesConfigData(), { ...mockFeaturesConfigJson, features: new Map(Object.entries({ - liveRecommendations: Object.assign(new FeatureConfig(), { - ...mockFeaturesConfigJson.features.liveRecommendations, + [KnownFeatures.InsightsRecommendations]: Object.assign(new FeatureConfig(), { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], filters: [ Object.assign(new FeatureConfigFilterOr(), { or: [ @@ -153,13 +156,18 @@ export const mockFeaturesConfigEntityComplex = Object.assign(new FeaturesConfigE }); export const mockFeature = Object.assign(new Feature(), { - name: 'liveRecommendations', + 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: 'liveRecommendations', + name: KnownFeatures.InsightsRecommendations, flag: true, }); @@ -175,10 +183,37 @@ export const mockFeaturesConfigRepository = jest.fn(() => ({ update: jest.fn().mockResolvedValue(mockFeaturesConfig), })); -export const mockFeaturesConfigService = () => ({ +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/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 786911b245..fb7d73d468 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -64,6 +64,12 @@ export enum TelemetryEvents { // Bulk Actions BulkActionsStarted = 'BULK_ACTIONS_STARTED', BulkActionsStopped = 'BULK_ACTIONS_STOPPED', + + // 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/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 7850c8d2cd..9284cb33df 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.ts @@ -19,7 +19,8 @@ export interface ITelemetryInitEvent { anonymousId: string; sessionId: number; appType: string; - controlGroup: number; + controlNumber: number; + controlGroup: string; } @Injectable() @@ -30,7 +31,9 @@ export class AnalyticsService { private appType: string = 'unknown'; - private controlGroup: number = -1; + private controlNumber: number = -1; + + private controlGroup: string = '-1'; private analytics; @@ -44,11 +47,14 @@ export class AnalyticsService { @OnEvent(AppAnalyticsEvents.Initialize) public initialize(payload: ITelemetryInitEvent) { - const { anonymousId, sessionId, appType, controlGroup } = 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, }); @@ -79,6 +85,7 @@ export class AnalyticsService { properties: { ...eventData, buildType: this.appType, + controlNumber: this.controlNumber, controlGroup: this.controlGroup, }, }); 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 index a7441d1f95..6ddc117a0f 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -11,14 +11,18 @@ export enum FeatureStorage { Env = 'env', Database = 'database', } +export enum FeatureConfigConfigDestination { + Default = 'default', + Remote = 'remote', +} -export enum FeatureRecalculationStrategy { - LiveRecommendation = 'liveRecommendation', +export enum KnownFeatures { + InsightsRecommendations = 'insightsRecommendations', } export const knownFeatures = [ { - name: 'liveRecommendations', + name: KnownFeatures.InsightsRecommendations, storage: FeatureStorage.Database, }, ]; 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.module.ts b/redisinsight/api/src/modules/feature/feature.module.ts index 96fe7598e5..4778aae255 100644 --- a/redisinsight/api/src/modules/feature/feature.module.ts +++ b/redisinsight/api/src/modules/feature/feature.module.ts @@ -9,6 +9,7 @@ import { FeatureRepository } from 'src/modules/feature/repositories/feature.repo 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 { @@ -24,6 +25,7 @@ export class FeatureModule { FeaturesConfigService, FeatureFlagProvider, FeatureGateway, + FeatureAnalytics, { provide: FeatureRepository, useClass: featureRepository, 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 index 94f4cddea4..7e1125ae08 100644 --- a/redisinsight/api/src/modules/feature/feature.service.ts +++ b/redisinsight/api/src/modules/feature/feature.service.ts @@ -5,21 +5,37 @@ import { FeatureServerEvents, FeatureStorage, knownFeatures } from 'src/modules/ 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 repository: FeatureRepository, - private featuresConfigRepository: FeaturesConfigRepository, - private featureFlagProvider: FeatureFlagProvider, - private eventEmitter: EventEmitter2, + private readonly repository: FeatureRepository, + private readonly featuresConfigRepository: FeaturesConfigRepository, + private readonly featureFlagProvider: FeatureFlagProvider, + private readonly eventEmitter: EventEmitter2, + private readonly analytics: FeatureAnalytics, ) {} - // todo: disable recommendations /** - * + * 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'); @@ -38,6 +54,14 @@ export class FeatureService { } }); + try { + this.analytics.sendFeatureFlagRecalculated({ + configVersion: (await this.featuresConfigRepository.getOrCreate())?.data?.version, + features, + }); + } catch (e) { + // ignore telemetry error + } return { features }; } @@ -68,12 +92,12 @@ export class FeatureService { })); // calculate to delete features - actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.data?.features?.[feature.name]); + actions.toDelete = featuresFromDatabase.filter((feature) => !featuresConfig?.data?.features?.has?.(feature.name)); // delete features - await Promise.all(actions.toDelete.map(this.repository.delete.bind(this.repository))); + await Promise.all(actions.toDelete.map((feature) => this.repository.delete(feature))); // upsert modified features - await Promise.all(actions.toUpsert.map(this.repository.upsert.bind(this.repository))); + 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}`, diff --git a/redisinsight/api/src/modules/feature/features-config.service.spec.ts b/redisinsight/api/src/modules/feature/features-config.service.spec.ts index 0169446e33..77d7818b99 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.spec.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import axios from 'axios'; import { mockControlGroup, - mockControlNumber, + mockControlNumber, mockFeatureAnalytics, mockFeaturesConfig, mockFeaturesConfigJson, mockFeaturesConfigRepository, @@ -13,7 +13,9 @@ import { FeaturesConfigRepository } from 'src/modules/feature/repositories/featu import { EventEmitter2 } from '@nestjs/event-emitter'; import { plainToClass } from 'class-transformer'; import { FeaturesConfigData } from 'src/modules/feature/model/features-config'; -import { FeatureServerEvents } from 'src/modules/feature/constants'; +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'); @@ -22,6 +24,7 @@ const mockedAxios = axios as jest.Mocked; describe('FeaturesConfigService', () => { let service: FeaturesConfigService; let repository: MockType; + let analytics: MockType; let eventEmitter: EventEmitter2; beforeEach(async () => { @@ -39,11 +42,16 @@ describe('FeaturesConfigService', () => { 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 }); @@ -62,43 +70,56 @@ describe('FeaturesConfigService', () => { it('should return remote config', async () => { const result = await service['getNewConfig'](); - expect(result).toEqual(mockFeaturesConfigJson); + 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(defaultConfig); + 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: JSON.stringify({ + data: { ...mockFeaturesConfigJson, features: { - liveRecommendations: { - ...mockFeaturesConfigJson.features.liveRecommendations, + [KnownFeatures.InsightsRecommendations]: { + ...mockFeaturesConfigJson.features[KnownFeatures.InsightsRecommendations], flag: 'not boolean flag', }, }, - }), + }, }); const result = await service['getNewConfig'](); - expect(result).toEqual(defaultConfig); + 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: JSON.stringify({ + data: { ...mockFeaturesConfigJson, version: defaultConfig.version - 0.1, - }), + }, }); const result = await service['getNewConfig'](); - expect(result).toEqual(defaultConfig); + expect(result).toEqual({ data: defaultConfig, type: FeatureConfigConfigDestination.Default }); + expect(analytics.sendFeatureFlagInvalidRemoteConfig).not.toHaveBeenCalled(); }); }); @@ -113,6 +134,11 @@ describe('FeaturesConfigService', () => { 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({ @@ -125,6 +151,7 @@ describe('FeaturesConfigService', () => { expect(repository.update).toHaveBeenCalledWith(mockFeaturesConfigJson); expect(eventEmitter.emit).not.toHaveBeenCalledWith(FeatureServerEvents.FeaturesRecalculate); + expect(analytics.sendFeatureFlagConfigUpdated).not.toHaveBeenCalled(); }); }); diff --git a/redisinsight/api/src/modules/feature/features-config.service.ts b/redisinsight/api/src/modules/feature/features-config.service.ts index df65d1257d..e4a0338e89 100644 --- a/redisinsight/api/src/modules/feature/features-config.service.ts +++ b/redisinsight/api/src/modules/feature/features-config.service.ts @@ -5,10 +5,12 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import config from 'src/utils/config'; import { FeaturesConfigRepository } from 'src/modules/feature/repositories/features-config.repository'; -import { FeatureServerEvents } from 'src/modules/feature/constants'; +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'); @@ -20,8 +22,9 @@ export class FeaturesConfigService { private validator = new Validator(); constructor( - private repository: FeaturesConfigRepository, - private eventEmitter: EventEmitter2, + private readonly repository: FeaturesConfigRepository, + private readonly eventEmitter: EventEmitter2, + private readonly analytics: FeatureAnalytics, ) {} async onApplicationBootstrap() { @@ -44,25 +47,37 @@ export class FeaturesConfigService { return data; } catch (error) { this.logger.error('Unable to fetch remote config', error); - throw error; + throw new UnableToFetchRemoteConfigException(); } } - private async getNewConfig(): Promise { - let newConfig: any = defaultConfig; + 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...'); - const remoteConfig = await this.fetchRemoteConfig(); + 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 = remoteConfig; + newConfig = { + data: remoteConfig, + type: FeatureConfigConfigDestination.Remote, + }; } } catch (error) { + this.analytics.sendFeatureFlagInvalidRemoteConfig({ + configVersion: remoteConfig?.version, + error, + }); + this.logger.error('Something wrong with remote config', error); } @@ -73,19 +88,31 @@ export class FeaturesConfigService { * 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(); - const newConfig = await this.getNewConfig(); - - if (newConfig?.version > currentConfig?.data?.version) { - await this.repository.update(newConfig); + 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); } } 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 index 14af16d6c2..e4a3091836 100644 --- 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 @@ -1,11 +1,12 @@ import { Injectable } from '@nestjs/common'; import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; import { - LiveRecommendationsFlagStrategy, -} from 'src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy'; + 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 { @@ -19,7 +20,7 @@ export class FeatureFlagProvider { this.featuresConfigService, this.settingsService, )); - this.strategies.set('liveRecommendations', new LiveRecommendationsFlagStrategy( + this.strategies.set(KnownFeatures.InsightsRecommendations, new InsightsRecommendationsFlagStrategy( this.featuresConfigService, this.settingsService, )); 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 index a16e25a95a..5eb7d3c35c 100644 --- 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 @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mockAppSettings, mockFeaturesConfig, - mockFeaturesConfigDataComplex, + mockFeaturesConfigDataComplex, mockFeaturesConfigJson, mockFeaturesConfigService, mockServerState, mockSettingsService, @@ -12,9 +12,15 @@ 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 { - LiveRecommendationsFlagStrategy, -} from 'src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy'; -import { FeatureConfigFilter, FeatureConfigFilterCondition } from 'src/modules/feature/model/features-config'; + 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; @@ -38,7 +44,7 @@ describe('FeatureFlagStrategy', () => { settingsService = module.get(SettingsService); featuresConfigService = module.get(FeaturesConfigService); - service = new LiveRecommendationsFlagStrategy( + service = new InsightsRecommendationsFlagStrategy( featuresConfigService as unknown as FeaturesConfigService, settingsService as unknown as SettingsService, ); @@ -89,6 +95,9 @@ describe('FeatureFlagStrategy', () => { }); 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(), { @@ -212,15 +221,38 @@ describe('FeatureFlagStrategy', () => { }); describe('filter (complex)', () => { - it('should return true since 2nd or is true', async () => { + 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('liveRecommendations').filters), - ).toEqual(true); + 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({ @@ -228,20 +260,160 @@ describe('FeatureFlagStrategy', () => { agreements: { analytics: false }, }); - expect( - await service['filter'](mockFeaturesConfigDataComplex.features.get('liveRecommendations').filters), - ).toEqual(false); + expect(await service['filter']( + mockFeaturesConfigDataComplex.features.get(KnownFeatures.InsightsRecommendations).filters, + )).toEqual(false); }); - it('should return true since all 1st or is true', async () => { + 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('liveRecommendations').filters), - ).toEqual(true); + 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/live-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts similarity index 82% rename from redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts rename to redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts index 55afed8b50..7984ad533c 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/live-recommendations.flag.strategy.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts @@ -1,6 +1,6 @@ import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; -export class LiveRecommendationsFlagStrategy extends FeatureFlagStrategy { +export class InsightsRecommendationsFlagStrategy extends FeatureFlagStrategy { async calculate(featureConfig: any): Promise { const isInRange = await this.isInTargetRange(featureConfig?.perc); diff --git a/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts index ff785e6a3c..ec2df934e8 100644 --- a/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts +++ b/redisinsight/api/src/modules/feature/repositories/local.feature.repository.ts @@ -2,7 +2,6 @@ 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'; 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/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 = { From 1359f38741f6ce34f36a1cc6d289dfa1b0d1fa72 Mon Sep 17 00:00:00 2001 From: Roman Sergeenko Date: Mon, 22 May 2023 09:06:36 +0200 Subject: [PATCH 30/55] #4399 - rename feature --- redisinsight/ui/src/constants/featureFlags.ts | 2 +- redisinsight/ui/src/pages/instance/InstancePage.spec.tsx | 4 ++-- redisinsight/ui/src/pages/instance/InstancePage.tsx | 2 +- redisinsight/ui/src/slices/app/features.ts | 4 ++-- redisinsight/ui/src/slices/tests/app/features.spec.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index 7ad469600d..e6b11f987f 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -1,3 +1,3 @@ export enum FeatureFlags { - liveRecommendations = 'liveRecommendations' + insightsRecommendations = 'insightsRecommendations' } diff --git a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx index 5674b8bec9..e895fc8508 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx @@ -21,7 +21,7 @@ jest.mock('uiSrc/services', () => ({ jest.mock('uiSrc/slices/app/features', () => ({ ...jest.requireActual('uiSrc/slices/app/features'), appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ - liveRecommendations: { + insightsRecommendations: { flag: false } }), @@ -72,7 +72,7 @@ describe('InstancePage', () => { it('should render LiveTimeRecommendations Component with feature flag', () => { (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ - liveRecommendations: { + insightsRecommendations: { flag: true } }) diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index df3e1428b1..53a4b94bc3 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -128,7 +128,7 @@ const InstancePage = ({ routes = [] }: Props) => { return ( <> - + { it('should properly set state', () => { const payload = { features: { - liveRecommendations: { + insightsRecommendations: { flag: true } } @@ -521,7 +521,7 @@ describe('slices', () => { describe('fetchFeatureFlags', () => { it('succeed to fetch data', async () => { // Arrange - const data = { features: { liveRecommendations: true } } + const data = { features: { insightsRecommendations: true } } const responsePayload = { data, status: 200 } apiService.get = jest.fn().mockResolvedValue(responsePayload) From e44c426f75414f21be53d448bf5d2d92efa47a1e Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 22 May 2023 10:53:52 +0300 Subject: [PATCH 31/55] #RI-4489 ITests --- redisinsight/api/config/test.ts | 9 + redisinsight/api/package.json | 2 +- redisinsight/api/test/api/deps.ts | 3 + .../api/test/api/feature/GET-features.test.ts | 181 ++++++++++++++++++ .../api/feature/POST-features-sync.test.ts | 140 ++++++++++++++ .../api/test/api/info/GET-info.test.ts | 2 + redisinsight/api/test/helpers/constants.ts | 3 + redisinsight/api/test/helpers/local-db.ts | 3 + .../api/test/helpers/remote-server.ts | 13 ++ redisinsight/api/test/helpers/test.ts | 3 +- .../api/test/test-runs/docker.build.yml | 1 + 11 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 redisinsight/api/test/api/feature/GET-features.test.ts create mode 100644 redisinsight/api/test/api/feature/POST-features-sync.test.ts create mode 100644 redisinsight/api/test/helpers/remote-server.ts diff --git a/redisinsight/api/config/test.ts b/redisinsight/api/config/test.ts index 7b087a949c..06b7141540 100644 --- a/redisinsight/api/config/test.ts +++ b/redisinsight/api/config/test.ts @@ -3,10 +3,19 @@ export default { env: 'test', requestTimeout: 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', }, + features_config: { + url: process.env.RI_FEATURES_CONFIG_URL + // eslint-disable-next-line max-len + || 'http://localhost:5551/remote/features-config.json', + }, }; 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/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..6d9ac01603 --- /dev/null +++ b/redisinsight/api/test/api/feature/GET-features.test.ts @@ -0,0 +1,181 @@ +import { + expect, + describe, + deps, + getMainCheckFn, fsExtra, before +} 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', () => { + 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/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index e587846db8..cbc9ed024e 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..77d24b1e99 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); 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: From 7e818957040c536cdd33bb914857e1017e31e601 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 22 May 2023 11:10:36 +0300 Subject: [PATCH 32/55] #RI-4489 fix Itests --- redisinsight/api/test/api/analytics/analytics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'); From 70ae48980140f0558e31cb64ae045da104052290 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 22 May 2023 11:49:13 +0200 Subject: [PATCH 33/55] console log added --- tests/e2e/helpers/insights.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index f6751b8d68..4cb9729132 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -10,6 +10,7 @@ const sqlite3 = require('sqlite3').verbose(); * @param controlNumber Control Number of user */ export function updateControlNumberInDB(controlNumber: Number): void { + console.log(dbPath); const db = new sqlite3.Database(dbPath); const query = `UPDATE features_config SET controlNumber = ${controlNumber}`; @@ -17,6 +18,12 @@ export function updateControlNumberInDB(controlNumber: Number): void { if (err) { return console.log(`error during changing controlNumber: ${err.message}`); } + db.get('SELECT controlNumber FROM features_config', (err: { message: string }, row: { controlNumber: number }) => { + if (err) { + return console.log(`error during retrieving controlNumber: ${err.message}`); + } + console.log('Updated controlNumber:', row.controlNumber); + }); }); db.close(); } From ccd9e40290f199f86c74ad69cd3a92d306f4865c Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 22 May 2023 12:59:24 +0200 Subject: [PATCH 34/55] add tests with changable static server file --- tests/e2e/helpers/api/api-info.ts | 13 ------ tests/e2e/helpers/insights.ts | 22 ++++++---- tests/e2e/package.json | 2 + .../features-config.json | 17 +++++++ .../insights/live-recommendations.e2e.ts | 41 ++++++++--------- tests/e2e/yarn.lock | 44 ++++++++++++++++++- 6 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 tests/e2e/test-data/insights-recommendations/features-config.json diff --git a/tests/e2e/helpers/api/api-info.ts b/tests/e2e/helpers/api/api-info.ts index a19cdfeb69..c327294ccc 100644 --- a/tests/e2e/helpers/api/api-info.ts +++ b/tests/e2e/helpers/api/api-info.ts @@ -12,16 +12,3 @@ export async function syncFeaturesApi(): Promise { .set('Accept', 'application/json'); await t.expect(response.status).eql(200, `Synchronization request failed: ${await response.body.message}`); } - -// /** -// * Initiate remote server to fetch various static data like notificaitons or features configs -// */ -// export const initRemoteServer = async () => { -// const path = '../../test-data/remote'; -// await fs.ensureDir(path); - -// const app = express(); -// app.use('/remote', express.static(path)); -// // Start the server -// await app.listen(3000); -// } diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 23bd7148a5..6cf9b9fa64 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import { workingDirectory} from '../helpers/conf'; +import axios from 'axios'; const dbPath = `${workingDirectory}/redisinsight.db`; @@ -35,12 +36,17 @@ export function updateControlNumberInDB(controlNumber: Number): void { } /** - * Update version into local features-config file - * @param filePath Path to config file - * @param version New version for features-config + * Update features-config file in static server + * @param filePath Path to feature config json */ -export function updateFeaturesConfigVersion(filePath: string, newVersion: Number): void { - const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf8')); - jsonData.version = newVersion; - fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2)); -} +export async function modifyFeaturesConfigJson(filePath: string): Promise { + const url = 'http://static-server:5551/remote/features-config.json'; + + try { + const data = fs.readFileSync(filePath, 'utf8'); + await axios.put(url, data); + console.log('Features config file updated successfully.'); + } catch (error) { + console.error('Error updating features config file:', error.message); + } +} \ No newline at end of file diff --git a/tests/e2e/package.json b/tests/e2e/package.json index dfb38b5243..1373d0314b 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -30,11 +30,13 @@ "@types/node": "18.11.9" }, "devDependencies": { + "@types/axios": "^0.14.0", "@types/chance": "1.1.3", "@types/edit-json-file": "1.7.0", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", + "axios": "^0.25.0", "chance": "1.1.8", "cross-env": "^7.0.3", "dotenv-cli": "^5.0.0", diff --git a/tests/e2e/test-data/insights-recommendations/features-config.json b/tests/e2e/test-data/insights-recommendations/features-config.json new file mode 100644 index 0000000000..f7de2012b4 --- /dev/null +++ b/tests/e2e/test-data/insights-recommendations/features-config.json @@ -0,0 +1,17 @@ +{ + "version": 5, + "features": { + "liveRecommendations": { + "flag": true, + "perc": [[30, 50]], + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + } + ] + } + } + } + \ No newline at end of file diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 79e339a7ae..35967fc460 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -12,7 +12,7 @@ import { syncFeaturesApi } from '../../../helpers/api/api-info'; import { Common } from '../../../helpers/common'; import { Telemetry } from '../../../helpers/telemetry'; import { RecommendationsActions } from '../../../common-actions/recommendations-actions'; -import { updateControlNumberInDB, updateFeaturesConfigVersion } from '../../../helpers/insights'; +import { modifyFeaturesConfigJson, updateControlNumberInDB } from '../../../helpers/insights'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -36,7 +36,7 @@ const expectedProperties = [ 'provider', 'vote' ]; -const updateControlNumber = async(number: Number): Promise => { +const updateControlNumber = async (number: Number): Promise => { updateControlNumberInDB(number); await syncFeaturesApi(); await browserPage.reloadPage(); @@ -46,17 +46,17 @@ const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; const setPasswordRecom = RecommendationIds.setPassword; -fixture `Live Recommendations` +fixture`Live Recommendations` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) - .beforeEach(async() => { + .beforeEach(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); }) - .afterEach(async() => { + .afterEach(async () => { // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test.only +test .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); await addNewStandaloneDatabaseApi(ossStandaloneConfig); @@ -94,18 +94,19 @@ test.only await updateControlNumber(30.1); await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); }); -test - .before(async t => { +test.only + .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - // // Update local config file to version highter than remote config - // updateFeaturesConfigVersion(featuresConfigPath, 5); - // await t.wait(5000); - // Update Control Number to be out of range from remote file - await updateControlNumber(45.92); + await updateControlNumber(19.2); }) .after(async () => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that config info is taken from file with higher version', async t => { + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed after enabling analytics'); + // Update local config file to version highter than remote config + await modifyFeaturesConfigJson('../../../test-data/insights-recommendations/features-config.json'); + // Update Control Number to be out of range from remote file + await updateControlNumber(35.2); // Verify that Insights panel displayed because range was taken from a local file with larger version than the remote file await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); }); @@ -131,7 +132,7 @@ test await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for Electron app when control number out of the config'); }); test - .before(async() => { + .before(async () => { // Add new databases using API await acceptLicenseTerms(); await addNewStandaloneDatabasesApi(databasesForAdding); @@ -139,7 +140,7 @@ test await myRedisDatabasePage.reloadPage(); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); }) - .after(async() => { + .after(async () => { // Clear and delete database await browserPage.InsightsPanel.toggleInsightsPanel(false); await browserPage.OverviewPanel.changeDbIndex(0); @@ -176,9 +177,9 @@ test }); test .requestHooks(logger) - .before(async() => { + .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - }).after(async() => { + }).after(async () => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that user can upvote recommendations', async t => { await browserPage.InsightsPanel.toggleInsightsPanel(true); @@ -242,9 +243,9 @@ test('Verify that user can snooze recommendation', async t => { await t.expect(await browserPage.InsightsPanel.getRecommendationByName(searchVisualizationRecom).exists).ok('recommendation is not displayed again'); }); test - .before(async() => { + .before(async () => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - }).after(async() => { + }).after(async () => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that recommendations from database analysis are displayed in Insight panel above live recommendations', async t => { const redisVersionRecomSelector = browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom); @@ -282,7 +283,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() => { + .after(async () => { 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/yarn.lock b/tests/e2e/yarn.lock index 677e337a53..a3b7ccab79 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -1228,6 +1228,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@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" @@ -1656,6 +1663,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" @@ -2015,7 +2038,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== @@ -2885,6 +2908,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" @@ -2906,6 +2934,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" @@ -4688,6 +4725,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" From 6cc96f7284c525319f8e006aba51cf9e370084ae Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 22 May 2023 17:55:21 +0300 Subject: [PATCH 35/55] #RI-4489 fix ITests --- redisinsight/api/config/test.ts | 6 +++--- .../WS-notifications-global-sync.test.ts} | 0 redisinsight/api/test/api/feature/GET-features.test.ts | 4 +++- redisinsight/api/test/test-runs/local.build.yml | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) rename redisinsight/api/test/api/{notifications/WS-global-sync.test.ts => _init/WS-notifications-global-sync.test.ts} (100%) diff --git a/redisinsight/api/config/test.ts b/redisinsight/api/config/test.ts index 06b7141540..f5214e8086 100644 --- a/redisinsight/api/config/test.ts +++ b/redisinsight/api/config/test.ts @@ -1,7 +1,7 @@ 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, @@ -11,11 +11,11 @@ export default { 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 - // eslint-disable-next-line max-len || 'http://localhost:5551/remote/features-config.json', }, }; 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/feature/GET-features.test.ts b/redisinsight/api/test/api/feature/GET-features.test.ts index 6d9ac01603..d6191c7c57 100644 --- a/redisinsight/api/test/api/feature/GET-features.test.ts +++ b/redisinsight/api/test/api/feature/GET-features.test.ts @@ -2,7 +2,7 @@ import { expect, describe, deps, - getMainCheckFn, fsExtra, before + getMainCheckFn, fsExtra, before, after, } from '../deps'; import { constants } from '../../helpers/constants'; import * as defaultConfig from '../../../config/features-config.json'; @@ -34,6 +34,8 @@ const waitForFlags = async (flags: any) => { let featureConfigRepository; let featureRepository; describe('GET /features', () => { + after(initSettings); + before(async () => { await initSettings(); featureConfigRepository = await getRepository(repositories.FEATURES_CONFIG); 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: From 0b8708488f04f92d5723e02a8bc92fdf0f65220b Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Mon, 22 May 2023 20:05:08 +0200 Subject: [PATCH 36/55] updates for filters --- tests/e2e/helpers/common.ts | 10 ++ tests/e2e/helpers/database-scripts.ts | 62 +++++++ tests/e2e/helpers/insights.ts | 55 ++---- tests/e2e/remote/features-config.json | 32 ++-- tests/e2e/static.ts | 18 +- .../insights-analytics-filter-off.json | 21 +++ .../insights-build-type-filter.json | 21 +++ .../features-configs/insights-electron.json | 21 +++ .../features-configs/insights-invalid.json | 20 +++ .../features-configs/insights-valid.json | 21 +++ .../features-config.json | 17 -- .../regression/insights/feature-flag.e2e.ts | 156 ++++++++++++++++++ .../insights/live-recommendations.e2e.ts | 84 ---------- 13 files changed, 377 insertions(+), 161 deletions(-) create mode 100644 tests/e2e/helpers/database-scripts.ts create mode 100644 tests/e2e/test-data/features-configs/insights-analytics-filter-off.json create mode 100644 tests/e2e/test-data/features-configs/insights-build-type-filter.json create mode 100644 tests/e2e/test-data/features-configs/insights-electron.json create mode 100644 tests/e2e/test-data/features-configs/insights-invalid.json create mode 100644 tests/e2e/test-data/features-configs/insights-valid.json delete mode 100644 tests/e2e/test-data/insights-recommendations/features-config.json create mode 100644 tests/e2e/tests/regression/insights/feature-flag.e2e.ts diff --git a/tests/e2e/helpers/common.ts b/tests/e2e/helpers/common.ts index 2d992c8f10..63f0a2aa4b 100644 --- a/tests/e2e/helpers/common.ts +++ b/tests/e2e/helpers/common.ts @@ -1,4 +1,5 @@ import { ClientFunction, RequestMock, t } from 'testcafe'; +import * as fs from 'fs'; import { Chance } from 'chance'; import { apiUrl, commonUrl } from './conf'; @@ -188,4 +189,13 @@ export class Common { static async getPageUrl(): Promise { return (await ClientFunction(() => window.location.href))(); } + + /** + * 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]; + } } diff --git a/tests/e2e/helpers/database-scripts.ts b/tests/e2e/helpers/database-scripts.ts new file mode 100644 index 0000000000..6dd5dbcd9a --- /dev/null +++ b/tests/e2e/helpers/database-scripts.ts @@ -0,0 +1,62 @@ +import { workingDirectory } from '../helpers/conf'; + +const dbPath = `${workingDirectory}/redisinsight.db`; + +const sqlite3 = require('sqlite3').verbose(); + +/** + * 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 = await new sqlite3.Database(dbPath); + const query = `UPDATE ${tableName} SET ${columnName} = ${value}`; + + await db.run(query, (err: { message: string }) => { + if (err) { + return console.error(`error during changing ${columnName} column value:`, err.message); + } + }); + await db.close(); +} + +/** + * 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 { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath); + const query = `SELECT ${columnName} FROM ${tableName}`; + + db.get(query, (err: { message: string }, row: any) => { + if (err) { + reject(err); + return console.error(`error during getting ${columnName} column value:`, err.message); + } + + 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 = await new sqlite3.Database(dbPath); + const query = `DELETE FROM ${tableName}`; + + await db.run(query, function(err: { message: string }) { + if (err) { + return console.error(`error during ${tableName} table rows deletion:`, err.message); + } + }); + await db.close(); +} diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 6cf9b9fa64..b5a40c1c64 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,52 +1,17 @@ -import * as fs from 'fs'; -import { workingDirectory} from '../helpers/conf'; -import axios from 'axios'; - -const dbPath = `${workingDirectory}/redisinsight.db`; - -const sqlite3 = require('sqlite3').verbose(); - -/** - * Update controlNumber parameter into local DB - * @param controlNumber Control Number of user - */ -export function updateControlNumberInDB(controlNumber: Number): void { - console.log(dbPath); - const db = new sqlite3.Database(dbPath); - const query = `UPDATE features_config SET controlNumber = ${controlNumber}`; - - db.run(query, function(err: { message: string }) { - if (err) { - return console.log(`error during changing controlNumber: ${err.message}`); - } - db.get('SELECT controlNumber FROM features_config', (err: { message: string }, row: { controlNumber: number }) => { - if (err) { - return console.log(`error during retrieving controlNumber: ${err.message}`); - } - console.log('Updated controlNumber:', row.controlNumber); - }); - db.get('SELECT data FROM features_config', (err: { message: string }, row: { data: string }) => { - if (err) { - return console.log(`error during retrieving data: ${err.message}`); - } - console.log('Updated data:', row.data); - }); - }); - db.close(); -} +import { execSync } from 'child_process'; /** * Update features-config file in static server * @param filePath Path to feature config json */ export async function modifyFeaturesConfigJson(filePath: string): Promise { - const url = 'http://static-server:5551/remote/features-config.json'; + const containerName = 'e2e-static-server-1'; - try { - const data = fs.readFileSync(filePath, 'utf8'); - await axios.put(url, data); - console.log('Features config file updated successfully.'); - } catch (error) { - console.error('Error updating features config file:', error.message); - } -} \ No newline at end of file + const command = `docker cp ${filePath} ${containerName}:/app/remote/features-config.json`; + try { + execSync(command); + } + catch (err) { + console.error('Error copying file to the static server container:', err.message); + } +} diff --git a/tests/e2e/remote/features-config.json b/tests/e2e/remote/features-config.json index 8777ad3cfd..330355b218 100644 --- a/tests/e2e/remote/features-config.json +++ b/tests/e2e/remote/features-config.json @@ -1,17 +1,21 @@ { - "version": 3, - "features": { - "liveRecommendations": { - "flag": true, - "perc": [[0, 20]], - "filters": [ - { - "name": "agreements.analytics", - "value": true, - "cond": "eq" - } - ] - } + "version": 0.9, + "features": { + "insightsRecommendations": { + "flag": true, + "perc": [[0,20]], + "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/static.ts b/tests/e2e/static.ts index b2a133b7e2..d465aa841b 100644 --- a/tests/e2e/static.ts +++ b/tests/e2e/static.ts @@ -1,8 +1,24 @@ const express = require('express'); const fs = require('fs-extra'); +const path = require('path'); fs.ensureDir('./remote'); +const jsonFilePath = path.join('/remote', 'features-config.json'); const app = express(); -app.use('/remote', express.static('./remote')) +app.use('/remote', express.static('./remote')); + +app.put('/remote/features-config.json', (req, res) => { + const jsonData = req.body; + fs.writeFile(jsonFilePath, JSON.stringify(jsonData), (err) => { + if (err) { + console.error('Error updating features config file:', err); + res.status(500).send('Error updating features config file'); + } else { + console.log('Features config file updated successfully.'); + res.send('Features config file updated successfully'); + } + }); + }); + 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..6af3166cea --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json @@ -0,0 +1,21 @@ +{ + "version": 9, + "features": { + "insightsRecommendations": { + "perc": [[44,50]], + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": false, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + } + ] + } + } +} 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..2954edc2c3 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-build-type-filter.json @@ -0,0 +1,21 @@ +{ + "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" + } + ] + } + } +} diff --git a/tests/e2e/test-data/features-configs/insights-electron.json b/tests/e2e/test-data/features-configs/insights-electron.json new file mode 100644 index 0000000000..e78b554b16 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-electron.json @@ -0,0 +1,21 @@ +{ + "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" + } + ] + } + } +} 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..68586b409d --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-invalid.json @@ -0,0 +1,20 @@ +{ + "version": 5, + "features": { + "insightsRecommendations": { + "flag": true, + "filters": [ + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + }, + { + "name": "config.server.buildType", + "value": "DOCKER_ON_PREMISE", + "cond": "eq" + } + ] + } + } +} 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..4cea2aa5d0 --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-valid.json @@ -0,0 +1,21 @@ +{ + "version": 8, + "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" + } + ] + } + } +} diff --git a/tests/e2e/test-data/insights-recommendations/features-config.json b/tests/e2e/test-data/insights-recommendations/features-config.json deleted file mode 100644 index f7de2012b4..0000000000 --- a/tests/e2e/test-data/insights-recommendations/features-config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": 5, - "features": { - "liveRecommendations": { - "flag": true, - "perc": [[30, 50]], - "filters": [ - { - "name": "agreements.analytics", - "value": true, - "cond": "eq" - } - ] - } - } - } - \ No newline at end of file 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..7ecbd88c63 --- /dev/null +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -0,0 +1,156 @@ +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 { syncFeaturesApi } from '../../../helpers/api/api-info'; +import { deleteRowsFromTableInDB, getColumnValueFromTableInDB, updateColumnValueInDBTable } from '../../../helpers/database-scripts'; +import { modifyFeaturesConfigJson } 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 updateControlNumber = async(number: number): Promise => { + updateColumnValueInDBTable(featuresConfigTable, 'controlNumber', number); + await syncFeaturesApi(); + await browserPage.reloadPage(); +}; +const redisVersionRecom = RecommendationIds.redisVersion; +const pathes = { + default: path.join('..', '..', 'redisinsight', 'api', 'config', 'features-config.json'), + simpleRemote: path.join('.', 'remote', 'features-config.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'), + electronConfig: path.join('.', 'test-data', 'features-configs', 'insights-electron.json') +}; + +fixture.only `Feature flag` + .meta({ type: 'regression', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + }) + .afterEach(async() => { + // Delete database + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.simpleRemote); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + await updateControlNumber(19.2); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + })('Verify that default config applied when remote config version is lower', async t => { + await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) + .eql(await Common.getJsonPropertyValue('version', pathes.default), 'Config with lowest version applied'); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when disabled in default config'); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.invalidConfig); + await updateControlNumber(19.2); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.simpleRemote); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + })('Verify that invaid remote config not applied even if its version is higher than in the default config', async t => { + await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) + .eql(await Common.getJsonPropertyValue('version', pathes.default), 'Config with invalid data 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); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.validConfig); + await updateControlNumber(48.2); + }) + .after(async t => { + await t.click(browserPage.NavigationPanel.settingsButton); + await settingsPage.changeAnalyticsSwitcher(true); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.simpleRemote); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + })('Verify that valid remote config applied with version higher than in the default config', async t => { + await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) + .eql(await Common.getJsonPropertyValue('version', pathes.validConfig), 'Config with invalid data applied'); + // Verify that config file updated from the GitHub repository if the GitHub file has the latest timestamp + 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); + // Verify that Insights panel displayed if user's controlNumber is in range from config file + 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 + await modifyFeaturesConfigJson(pathes.analyticsConfig); + await updateControlNumber(48.2); + 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 the local config file has it disabled + await updateControlNumber(30.1); + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); + + // Verify that buildType filter applied + await modifyFeaturesConfigJson(pathes.buildTypeConfig); + await updateControlNumber(48.2); + await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) + .eql(await Common.getJsonPropertyValue('version', pathes.buildTypeConfig), 'Config with lowest version 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); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.validConfig); + await updateControlNumber(48.2); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + // Update remote config .json + await modifyFeaturesConfigJson(pathes.simpleRemote); + // Clear features config table + await deleteRowsFromTableInDB(featuresConfigTable); + })('Verify that Insights panel can be displayed for Electron app according to filters', async t => { + await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed when filter excludes this buildType'); + // Update remote config .json + 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 35967fc460..ef09311f0b 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -3,16 +3,13 @@ import { RecommendationIds, rte, env } from '../../../helpers/constants'; import { acceptLicenseTerms, acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; import { - addNewStandaloneDatabaseApi, addNewStandaloneDatabasesApi, deleteStandaloneDatabaseApi, deleteStandaloneDatabasesApi } from '../../../helpers/api/api-database'; -import { syncFeaturesApi } from '../../../helpers/api/api-info'; import { Common } from '../../../helpers/common'; import { Telemetry } from '../../../helpers/telemetry'; import { RecommendationsActions } from '../../../common-actions/recommendations-actions'; -import { modifyFeaturesConfigJson, updateControlNumberInDB } from '../../../helpers/insights'; const myRedisDatabasePage = new MyRedisDatabasePage(); const browserPage = new BrowserPage(); @@ -20,7 +17,6 @@ const workbenchPage = new WorkbenchPage(); const telemetry = new Telemetry(); const memoryEfficiencyPage = new MemoryEfficiencyPage(); const recommendationsActions = new RecommendationsActions(); -const settingsPage = new SettingsPage(); const databasesForAdding = [ { host: ossStandaloneV5Config.host, port: ossStandaloneV5Config.port, databaseName: ossStandaloneV5Config.databaseName }, @@ -36,11 +32,6 @@ const expectedProperties = [ 'provider', 'vote' ]; -const updateControlNumber = async (number: Number): Promise => { - updateControlNumberInDB(number); - await syncFeaturesApi(); - await browserPage.reloadPage(); -}; const redisVersionRecom = RecommendationIds.redisVersion; const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; @@ -56,81 +47,6 @@ fixture`Live Recommendations` // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); }); -test - .before(async () => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await addNewStandaloneDatabaseApi(ossStandaloneConfig); - await updateControlNumber(19.2); - }) - .after(async () => { - await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - })('Verify that Insights panel displayed if the local config file has it enabled', async t => { - // Verify that config file updated from the GitHub repository if the GitHub file has the latest timestamp - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); - await browserPage.InsightsPanel.toggleInsightsPanel(true); - // Verify that Insights panel displayed if user's controlNumber is in range from config file - await t.expect(browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); - - await browserPage.InsightsPanel.toggleInsightsPanel(false); - // Verify that recommendations displayed for all databases if option enabled - await t.click(browserPage.OverviewPanel.myRedisDbIcon); - await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for the other db connection'); - - // 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'); - - // Turn on telemetry - await t.click(browserPage.NavigationPanel.settingsButton); - await settingsPage.changeAnalyticsSwitcher(true); - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed after enabling analytics'); - - // Verify that Insights panel not displayed if the local config file has it disabled - await updateControlNumber(30.1); - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); - }); -test.only - .before(async () => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await updateControlNumber(19.2); - }) - .after(async () => { - await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - })('Verify that config info is taken from file with higher version', async t => { - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed after enabling analytics'); - // Update local config file to version highter than remote config - await modifyFeaturesConfigJson('../../../test-data/insights-recommendations/features-config.json'); - // Update Control Number to be out of range from remote file - await updateControlNumber(35.2); - // Verify that Insights panel displayed because range was taken from a local file with larger version than the remote file - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); - }); -test - .meta({ env: env.desktop }) - .before(async () => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await updateControlNumber(60.0); - }) - .after(async () => { - await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - })('Verify that Insights panel can be displayed for Electron/WebStack app according to filters', async t => { - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed for user with control number within the config'); - - // 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).ok('Insights panel not displayed without analytics when its filter is off'); - - // Verify that Insights panel not displayed if the local config file has it disabled - await updateControlNumber(83.1); - await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for Electron app when control number out of the config'); - }); test .before(async () => { // Add new databases using API From dadb810f87ddacc80410a463b512c6246ab2325f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 15:55:50 +0200 Subject: [PATCH 37/55] updates for static server config --- .gitignore | 2 + tests/e2e/.desktop.env | 1 + tests/e2e/.env | 1 + tests/e2e/.gitignore | 4 + tests/e2e/docker.web.docker-compose.yml | 7 +- tests/e2e/helpers/database-scripts.ts | 24 +++--- tests/e2e/helpers/insights.ts | 41 ++++++--- tests/e2e/local.web.docker-compose.yml | 7 +- tests/e2e/remote/features-config.json | 24 ++++-- tests/e2e/static.ts | 18 +--- .../insights-analytics-filter-off.json | 24 ++++-- .../insights-build-type-filter.json | 9 +- .../insights-default-remote.json | 35 ++++++++ .../insights-docker-build.json | 26 ++++++ ...tron.json => insights-electron-build.json} | 9 +- .../features-configs/insights-invalid.json | 17 +++- .../features-configs/insights-valid.json | 24 ++++-- .../regression/insights/feature-flag.e2e.ts | 83 ++++++++++--------- 18 files changed, 246 insertions(+), 110 deletions(-) create mode 100644 tests/e2e/test-data/features-configs/insights-default-remote.json create mode 100644 tests/e2e/test-data/features-configs/insights-docker-build.json rename tests/e2e/test-data/features-configs/{insights-electron.json => insights-electron-build.json} (82%) 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/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 08fd72e27f..11b058ebbe 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -29,3 +29,4 @@ NOTIFICATION_SYNC_INTERVAL=30000 RI_FEATURES_CONFIG_URL=http://localhost:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 +REMOTE_FOLDER_PATH=./remote diff --git a/tests/e2e/.env b/tests/e2e/.env index b86cfd812c..f5f61fb831 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -6,3 +6,4 @@ NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/ NOTIFICATION_SYNC_INTERVAL=30000 RI_FEATURES_CONFIG_URL=http://static-server:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 +REMOTE_FOLDER_PATH=./remote 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 951886955e..9084f53620 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -6,8 +6,7 @@ services: context: . dockerfile: static-server.Dockerfile volumes: - - ./static.ts:/app/static.ts - - static-server-data:/app/remote + - ./remote:/app/remote ports: - 5551:5551 @@ -24,6 +23,7 @@ services: - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh + - ./remote:/root/remote env_file: - ./.env entrypoint: [ @@ -54,6 +54,3 @@ services: - .ritmp:/tmp - ./test-data/certs:/root/certs - ./test-data/ssh:/root/ssh - -volumes: - static-server-data: diff --git a/tests/e2e/helpers/database-scripts.ts b/tests/e2e/helpers/database-scripts.ts index 6dd5dbcd9a..7dfd625edf 100644 --- a/tests/e2e/helpers/database-scripts.ts +++ b/tests/e2e/helpers/database-scripts.ts @@ -1,9 +1,8 @@ import { workingDirectory } from '../helpers/conf'; +const sqlite3 = require('sqlite3').verbose(); const dbPath = `${workingDirectory}/redisinsight.db`; -const sqlite3 = require('sqlite3').verbose(); - /** * Update table column value into local DB * @param tableName The name of table in DB @@ -11,15 +10,18 @@ const sqlite3 = require('sqlite3').verbose(); * @param value Value to update in table */ export async function updateColumnValueInDBTable(tableName: string, columnName: string, value: number | string): Promise { - const db = await new sqlite3.Database(dbPath); - const query = `UPDATE ${tableName} SET ${columnName} = ${value}`; + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath); + const query = `UPDATE ${tableName} SET ${columnName} = ${value}`; - await db.run(query, (err: { message: string }) => { - if (err) { - return console.error(`error during changing ${columnName} column value:`, err.message); - } + db.run(query, (err: { message: string }) => { + if (err) { + reject(new Error(`Error during changing ${columnName} column value: ${err.message}`)); + } + db.close(); + resolve(); + }); }); - await db.close(); } /** @@ -34,10 +36,8 @@ export async function getColumnValueFromTableInDB(tableName: string, columnName: db.get(query, (err: { message: string }, row: any) => { if (err) { - reject(err); - return console.error(`error during getting ${columnName} column value:`, err.message); + reject(new Error(`Error during getting ${columnName} column value: ${err.message}`)); } - const columnValue = row[columnName]; db.close(); resolve(columnValue); diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index b5a40c1c64..7fd4b8cd11 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,17 +1,38 @@ -import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { BasePage } from '../pageObjects'; +import { updateColumnValueInDBTable } from './database-scripts'; +import { syncFeaturesApi } from './api/api-info'; + +const basePage = new BasePage(); /** - * Update features-config file in static server + * Update features-config file for static server * @param filePath Path to feature config json */ export async function modifyFeaturesConfigJson(filePath: string): Promise { - const containerName = 'e2e-static-server-1'; + const configFileName = 'features-config.json'; + const remoteConfigPath = process.env.REMOTE_FOLDER_PATH || './remote'; + + return new Promise((resolve, reject) => { + try { + fs.writeFileSync(path.join(remoteConfigPath, configFileName), 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'; - const command = `docker cp ${filePath} ${containerName}:/app/remote/features-config.json`; - try { - execSync(command); - } - catch (err) { - console.error('Error copying file to the static server container:', err.message); - } + updateColumnValueInDBTable(featuresConfigTable, 'controlNumber', controlNumber); + await syncFeaturesApi(); + await basePage.reloadPage(); } diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index a23b49a6f7..a7b8d9aefa 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -6,8 +6,7 @@ services: context: . dockerfile: static-server.Dockerfile volumes: - - ./static.ts:/app/static.ts - - static-server-data:/app/remote + - ./remote:/app/remote ports: - 5551:5551 @@ -22,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: @@ -57,6 +57,3 @@ services: - ./test-data/ssh:/root/ssh ports: - 5000:5000 - -volumes: - static-server-data: diff --git a/tests/e2e/remote/features-config.json b/tests/e2e/remote/features-config.json index 330355b218..876a75516d 100644 --- a/tests/e2e/remote/features-config.json +++ b/tests/e2e/remote/features-config.json @@ -3,7 +3,12 @@ "features": { "insightsRecommendations": { "flag": true, - "perc": [[0,20]], + "perc": [ + [ + 0, + 20 + ] + ], "filters": [ { "name": "agreements.analytics", @@ -11,11 +16,20 @@ "cond": "eq" }, { - "name": "config.server.buildType", - "value": "DOCKER_ON_PREMISE", - "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/static.ts b/tests/e2e/static.ts index d465aa841b..a70a044b16 100644 --- a/tests/e2e/static.ts +++ b/tests/e2e/static.ts @@ -1,24 +1,10 @@ const express = require('express'); const fs = require('fs-extra'); -const path = require('path'); -fs.ensureDir('./remote'); -const jsonFilePath = path.join('/remote', 'features-config.json'); +fs.mkdirSync('./remote', { recursive: true }); +fs.copyFileSync('./test-data/features-configs/insights-default-remote.json', './remote/features-config.json'); const app = express(); app.use('/remote', express.static('./remote')); -app.put('/remote/features-config.json', (req, res) => { - const jsonData = req.body; - fs.writeFile(jsonFilePath, JSON.stringify(jsonData), (err) => { - if (err) { - console.error('Error updating features config file:', err); - res.status(500).send('Error updating features config file'); - } else { - console.log('Features config file updated successfully.'); - res.send('Features config file updated successfully'); - } - }); - }); - 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 index 6af3166cea..e82e90b2e3 100644 --- a/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json +++ b/tests/e2e/test-data/features-configs/insights-analytics-filter-off.json @@ -2,7 +2,12 @@ "version": 9, "features": { "insightsRecommendations": { - "perc": [[44,50]], + "perc": [ + [ + 44, + 50 + ] + ], "flag": true, "filters": [ { @@ -11,11 +16,20 @@ "cond": "eq" }, { - "name": "config.server.buildType", - "value": "DOCKER_ON_PREMISE", - "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 index 2954edc2c3..56f4c595f5 100644 --- a/tests/e2e/test-data/features-configs/insights-build-type-filter.json +++ b/tests/e2e/test-data/features-configs/insights-build-type-filter.json @@ -2,7 +2,12 @@ "version": 15, "features": { "insightsRecommendations": { - "perc": [[44,50]], + "perc": [ + [ + 44, + 50 + ] + ], "flag": true, "filters": [ { @@ -18,4 +23,4 @@ ] } } -} +} \ 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..fb583c5efc --- /dev/null +++ b/tests/e2e/test-data/features-configs/insights-docker-build.json @@ -0,0 +1,26 @@ +{ + "version": 8, + "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.json b/tests/e2e/test-data/features-configs/insights-electron-build.json similarity index 82% rename from tests/e2e/test-data/features-configs/insights-electron.json rename to tests/e2e/test-data/features-configs/insights-electron-build.json index e78b554b16..b37f61526d 100644 --- a/tests/e2e/test-data/features-configs/insights-electron.json +++ b/tests/e2e/test-data/features-configs/insights-electron-build.json @@ -2,7 +2,12 @@ "version": 20, "features": { "insightsRecommendations": { - "perc": [[44,50]], + "perc": [ + [ + 44, + 50 + ] + ], "flag": true, "filters": [ { @@ -18,4 +23,4 @@ ] } } -} +} \ 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 index 68586b409d..118e6dcb36 100644 --- a/tests/e2e/test-data/features-configs/insights-invalid.json +++ b/tests/e2e/test-data/features-configs/insights-invalid.json @@ -10,11 +10,20 @@ "cond": "eq" }, { - "name": "config.server.buildType", - "value": "DOCKER_ON_PREMISE", - "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 index 4cea2aa5d0..ffa5f8f4b1 100644 --- a/tests/e2e/test-data/features-configs/insights-valid.json +++ b/tests/e2e/test-data/features-configs/insights-valid.json @@ -2,7 +2,12 @@ "version": 8, "features": { "insightsRecommendations": { - "perc": [[44,50]], + "perc": [ + [ + 44, + 50 + ] + ], "flag": true, "filters": [ { @@ -11,11 +16,20 @@ "cond": "eq" }, { - "name": "config.server.buildType", - "value": "DOCKER_ON_PREMISE", - "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/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index 7ecbd88c63..ad1cf3197c 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -7,9 +7,8 @@ import { addNewStandaloneDatabaseApi, deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; -import { syncFeaturesApi } from '../../../helpers/api/api-info'; -import { deleteRowsFromTableInDB, getColumnValueFromTableInDB, updateColumnValueInDBTable } from '../../../helpers/database-scripts'; -import { modifyFeaturesConfigJson } from '../../../helpers/insights'; +import { deleteRowsFromTableInDB, getColumnValueFromTableInDB } from '../../../helpers/database-scripts'; +import { modifyFeaturesConfigJson, updateControlNumber } from '../../../helpers/insights'; import { Common } from '../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -17,20 +16,15 @@ const browserPage = new BrowserPage(); const settingsPage = new SettingsPage(); const featuresConfigTable = 'features_config'; -const updateControlNumber = async(number: number): Promise => { - updateColumnValueInDBTable(featuresConfigTable, 'controlNumber', number); - await syncFeaturesApi(); - await browserPage.reloadPage(); -}; const redisVersionRecom = RecommendationIds.redisVersion; const pathes = { - default: path.join('..', '..', 'redisinsight', 'api', 'config', 'features-config.json'), - simpleRemote: path.join('.', 'remote', 'features-config.json'), + 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'), - electronConfig: path.join('.', 'test-data', 'features-configs', 'insights-electron.json') + dockerConfig: path.join('.', 'test-data', 'features-configs', 'insights-docker-build.json'), + electronConfig: path.join('.', 'test-data', 'features-configs', 'insights-electron-build.json') }; fixture.only `Feature flag` @@ -46,8 +40,8 @@ fixture.only `Feature flag` test .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.simpleRemote); + // Update remote config .json to default + await modifyFeaturesConfigJson(pathes.defaultRemote); // Clear features config table await deleteRowsFromTableInDB(featuresConfigTable); await updateControlNumber(19.2); @@ -57,48 +51,55 @@ test // Clear features config table await deleteRowsFromTableInDB(featuresConfigTable); })('Verify that default config applied when remote config version is lower', async t => { - await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) - .eql(await Common.getJsonPropertyValue('version', pathes.default), 'Config with lowest version applied'); + 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 .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.invalidConfig); - await updateControlNumber(19.2); }) .after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.simpleRemote); + // Update remote config .json to default + await modifyFeaturesConfigJson(pathes.defaultRemote); // Clear features config table await deleteRowsFromTableInDB(featuresConfigTable); })('Verify that invaid remote config not applied even if its version is higher than in the default config', async t => { - await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) - .eql(await Common.getJsonPropertyValue('version', pathes.default), 'Config with invalid data applied'); + // 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 with lowest version 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); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.validConfig); - await updateControlNumber(48.2); }) .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 - await modifyFeaturesConfigJson(pathes.simpleRemote); + // Update remote config .json to default + await modifyFeaturesConfigJson(pathes.defaultRemote); // Clear features config table await deleteRowsFromTableInDB(featuresConfigTable); })('Verify that valid remote config applied with version higher than in the default config', async t => { - await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) - .eql(await Common.getJsonPropertyValue('version', pathes.validConfig), 'Config with invalid data applied'); + // 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 config file updated from the GitHub repository if the GitHub file has the latest timestamp await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed when enabled from remote config'); @@ -117,7 +118,7 @@ test 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 + // Update remote config .json to config without analytics filter await modifyFeaturesConfigJson(pathes.analyticsConfig); await updateControlNumber(48.2); await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed without analytics when its filter is off'); @@ -126,30 +127,34 @@ test await updateControlNumber(30.1); await t.expect(browserPage.InsightsPanel.insightsBtn.exists).notOk('Insights panel displayed for user with control number out of the config'); - // Verify that buildType filter applied + // Update remote config .json to config with buildType filter excluding current app build await modifyFeaturesConfigJson(pathes.buildTypeConfig); await updateControlNumber(48.2); - await t.expect(await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version) - .eql(await Common.getJsonPropertyValue('version', pathes.buildTypeConfig), 'Config with lowest version applied'); + + // 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 with lowest version 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); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.validConfig); - await updateControlNumber(48.2); }) .after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - // Update remote config .json - await modifyFeaturesConfigJson(pathes.simpleRemote); + // 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 + + // 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'); From 5980939170f5d1d6fda5a2d342c2cb7db38d0217 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 17:21:43 +0200 Subject: [PATCH 38/55] update --- tests/e2e/helpers/database-scripts.ts | 44 +++--- tests/e2e/helpers/insights.ts | 17 ++- tests/e2e/package.json | 5 +- .../insights-docker-build.json | 2 +- .../features-configs/insights-flag-off.json | 35 +++++ .../regression/insights/feature-flag.e2e.ts | 42 ++--- tests/e2e/yarn.lock | 143 +++--------------- 7 files changed, 123 insertions(+), 165 deletions(-) create mode 100644 tests/e2e/test-data/features-configs/insights-flag-off.json diff --git a/tests/e2e/helpers/database-scripts.ts b/tests/e2e/helpers/database-scripts.ts index 7dfd625edf..9bc7ff95a9 100644 --- a/tests/e2e/helpers/database-scripts.ts +++ b/tests/e2e/helpers/database-scripts.ts @@ -1,5 +1,5 @@ import { workingDirectory } from '../helpers/conf'; -const sqlite3 = require('sqlite3').verbose(); +import * as sqlite3 from 'sqlite3'; const dbPath = `${workingDirectory}/redisinsight.db`; @@ -10,16 +10,17 @@ const dbPath = `${workingDirectory}/redisinsight.db`; * @param value Value to update in table */ export async function updateColumnValueInDBTable(tableName: string, columnName: string, value: number | string): Promise { - return new Promise((resolve, reject) => { - const db = new sqlite3.Database(dbPath); - const query = `UPDATE ${tableName} SET ${columnName} = ${value}`; + 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(); } - db.close(); - resolve(); }); }); } @@ -30,17 +31,18 @@ export async function updateColumnValueInDBTable(tableName: string, columnName: * @param columnName The name of column in table */ export async function getColumnValueFromTableInDB(tableName: string, columnName: string): Promise { - return new Promise((resolve, reject) => { - const db = new sqlite3.Database(dbPath); - const query = `SELECT ${columnName} FROM ${tableName}`; + 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); } - const columnValue = row[columnName]; - db.close(); - resolve(columnValue); }); }); } @@ -50,13 +52,19 @@ export async function getColumnValueFromTableInDB(tableName: string, columnName: * @param tableName The name of table in DB */ export async function deleteRowsFromTableInDB(tableName: string): Promise { - const db = await new sqlite3.Database(dbPath); + const db = new sqlite3.Database(dbPath); const query = `DELETE FROM ${tableName}`; - await db.run(query, function(err: { message: string }) { - if (err) { - return console.error(`error during ${tableName} table rows deletion:`, err.message); - } + 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(); + } + }); }); - await db.close(); } diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 7fd4b8cd11..da43790948 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { BasePage } from '../pageObjects'; -import { updateColumnValueInDBTable } from './database-scripts'; +import { deleteRowsFromTableInDB, updateColumnValueInDBTable } from './database-scripts'; import { syncFeaturesApi } from './api/api-info'; const basePage = new BasePage(); @@ -32,7 +32,20 @@ export async function modifyFeaturesConfigJson(filePath: string): Promise export async function updateControlNumber(controlNumber: number): Promise { const featuresConfigTable = 'features_config'; - updateColumnValueInDBTable(featuresConfigTable, 'controlNumber', controlNumber); + 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/package.json b/tests/e2e/package.json index 104b5c5988..cd199ffb3f 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -31,15 +31,16 @@ "@types/node": "18.11.9" }, "devDependencies": { - "@types/axios": "^0.14.0", "@types/archiver": "^5.3.2", + "@types/axios": "^0.14.0", "@types/chance": "1.1.3", "@types/edit-json-file": "1.7.0", + "@types/sqlite3": "^3.1.8", "@types/supertest": "^2.0.8", "@typescript-eslint/eslint-plugin": "4.28.2", "@typescript-eslint/parser": "4.28.2", - "axios": "^0.25.0", "archiver": "^5.3.1", + "axios": "^0.25.0", "chance": "1.1.8", "cross-env": "^7.0.3", "dotenv-cli": "^5.0.0", diff --git a/tests/e2e/test-data/features-configs/insights-docker-build.json b/tests/e2e/test-data/features-configs/insights-docker-build.json index fb583c5efc..969bdd4d51 100644 --- a/tests/e2e/test-data/features-configs/insights-docker-build.json +++ b/tests/e2e/test-data/features-configs/insights-docker-build.json @@ -1,5 +1,5 @@ { - "version": 8, + "version": 11, "features": { "insightsRecommendations": { "perc": [ 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/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index ad1cf3197c..0f7a88c9dc 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -8,7 +8,7 @@ import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { deleteRowsFromTableInDB, getColumnValueFromTableInDB } from '../../../helpers/database-scripts'; -import { modifyFeaturesConfigJson, updateControlNumber } from '../../../helpers/insights'; +import { modifyFeaturesConfigJson, refreshFeaturesTestData, updateControlNumber } from '../../../helpers/insights'; import { Common } from '../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); @@ -23,6 +23,7 @@ const pathes = { 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') }; @@ -41,31 +42,26 @@ test .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); // Update remote config .json to default - await modifyFeaturesConfigJson(pathes.defaultRemote); - // Clear features config table - await deleteRowsFromTableInDB(featuresConfigTable); - await updateControlNumber(19.2); + await refreshFeaturesTestData(); }) .after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - // Clear features config table - await deleteRowsFromTableInDB(featuresConfigTable); + await refreshFeaturesTestData(); })('Verify that default config applied when remote config version is lower', async t => { const featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; + await updateControlNumber(19.2); 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 .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + await refreshFeaturesTestData(); }) .after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - // Update remote config .json to default - await modifyFeaturesConfigJson(pathes.defaultRemote); - // Clear features config table - await deleteRowsFromTableInDB(featuresConfigTable); + await refreshFeaturesTestData(); })('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); @@ -73,13 +69,14 @@ test const featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; - await t.expect(featureVersion).eql(1, 'Config with lowest version applied'); + 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 @@ -89,9 +86,7 @@ test await deleteStandaloneDatabaseApi(ossStandaloneConfig); await deleteStandaloneDatabaseApi(ossStandaloneV5Config); // Update remote config .json to default - await modifyFeaturesConfigJson(pathes.defaultRemote); - // Clear features config table - await deleteRowsFromTableInDB(featuresConfigTable); + 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); @@ -99,8 +94,9 @@ test 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 config file updated from the GitHub repository if the GitHub file has the latest timestamp + 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 @@ -125,16 +121,25 @@ test // Verify that Insights panel not displayed if the local config file has it disabled await updateControlNumber(30.1); + // Verify that Insights panel can be displayed for Electron/WebStack app according to filters 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 with lowest version applied'); + 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 @@ -157,5 +162,6 @@ test // Update remote config .json to config with buildType filter including current app build await modifyFeaturesConfigJson(pathes.electronConfig); await updateControlNumber(48.2); + // Verify that Insights panel can be displayed for Electron/WebStack app according to filters await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed when filter includes this buildType'); }); diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock index 274859d4c7..f989268e12 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -1044,16 +1044,6 @@ dependencies: stackframe "^1.1.1" -"@electron/asar@^3.2.3": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.2.4.tgz#7e8635a3c4f6d8b3f8ae6efaf5ecb9fbf3bd9864" - integrity sha512-lykfY3TJRRWFeTxccEKdf1I6BLl2Plw81H0bbp4Fc5iEc67foDCa5pjJQULVgo0wF+Dli75f3xVcdb/67FFZ/g== - dependencies: - chromium-pickle-js "^0.2.0" - commander "^5.0.0" - glob "^7.1.6" - minimatch "^3.0.4" - "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -1228,12 +1218,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== -"@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/archiver@^5.3.2": version "5.3.2" resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.2.tgz#a9f0bcb0f0b991400e7766d35f6e19d163bdadcc" @@ -1241,6 +1225,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" @@ -1309,6 +1300,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" @@ -1405,13 +1403,6 @@ acorn-hammerhead@0.4.0: dependencies: "@types/estree" "0.0.46" -acorn-hammerhead@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/acorn-hammerhead/-/acorn-hammerhead-0.6.1.tgz#f8f27c58ceaf90fbdb77a92f4331a678271194f2" - integrity sha512-ZWG/nXPvFiveXhJq/PxuS+4LI1BqtEOviGXWjlTvI+64kwzaddYNaE0UzLorTX7kyxrFtxjJ4w1LmKN5yEzOCg== - dependencies: - "@types/estree" "0.0.46" - acorn-jsx@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -2131,11 +2122,6 @@ commander@^2.20.0, commander@^2.8.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -2390,14 +2376,6 @@ depd@^2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -des.js@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" - integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - detect-libc@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" @@ -2752,13 +2730,6 @@ esotope-hammerhead@0.6.1: dependencies: "@types/estree" "0.0.46" -esotope-hammerhead@0.6.4: - version "0.6.4" - resolved "https://registry.yarnpkg.com/esotope-hammerhead/-/esotope-hammerhead-0.6.4.tgz#e3b8ae5fbe5954aafc5bf507b8399560500be8b0" - integrity sha512-QY4HXqvjLSFGoGgHvm3H1QUMNcpwnUpGRBaVVFWE5uqbPQh9HSWcA1YD7KwwL/IrgerDwZn00z5dtYT9Ot/C/A== - dependencies: - "@types/estree" "0.0.46" - espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" @@ -3278,12 +3249,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.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== @@ -3405,21 +3371,6 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" -httpntlm@^1.8.10: - version "1.8.12" - resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.8.12.tgz#1b2f46e9839cc8434cced1b120d8689fdbd19387" - integrity sha512-dqdye5b5OmzCIDrA2JgkKG7bV9sK0S5VUELD1+JcRZG6ZDieAW7/c0MPsqlTRKDzso1tIMhvDQAWvfgFN0yg3A== - dependencies: - des.js "^1.0.1" - httpreq ">=0.4.22" - js-md4 "^0.3.2" - underscore "~1.12.1" - -httpreq@>=0.4.22: - version "0.5.2" - resolved "https://registry.yarnpkg.com/httpreq/-/httpreq-0.5.2.tgz#be6777292fa1038d7771d7c01d9a5e1219de951c" - integrity sha512-2Jm+x9WkExDOeFRrdBCBSpLPT5SokTcRHkunV3pjKmX/cx6av8zQ0WtHUMDrYb6O4hBFzNU6sxJEypvRUVYKnw== - https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -3881,11 +3832,6 @@ iterate-object@^1.3.4: resolved "https://registry.yarnpkg.com/iterate-object/-/iterate-object-1.3.4.tgz#fa50b1d9e58e340a7dd6b4c98c8a5e182e790096" integrity sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw== -js-md4@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" - integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== - js-message@1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/js-message/-/js-message-1.0.7.tgz#fbddd053c7a47021871bb8b2c95397cc17c20e47" @@ -4255,11 +4201,6 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimalistic-assert@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -4897,11 +4838,6 @@ proxyquire@^1.7.10: module-not-found-error "^1.0.0" resolve "~1.1.7" -psl@^1.1.33: - version "1.9.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -4915,7 +4851,7 @@ punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== @@ -4966,7 +4902,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== @@ -4979,29 +4915,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== @@ -5758,7 +5672,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" @@ -5964,15 +5878,6 @@ tough-cookie@2.3.3: dependencies: punycode "^1.4.1" -tough-cookie@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" - integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== - dependencies: - psl "^1.1.33" - punycode "^2.1.1" - universalify "^0.1.2" - tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -6094,11 +5999,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -underscore@~1.12.1: - version "1.12.1" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" - integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -6146,11 +6046,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -universalify@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - unquote@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" From e3f77b3e3fdbc666c2a18e2abed9e76a3c862dbb Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 17:22:05 +0200 Subject: [PATCH 39/55] upd --- tests/e2e/remote/features-config.json | 35 --------------------------- 1 file changed, 35 deletions(-) delete mode 100644 tests/e2e/remote/features-config.json diff --git a/tests/e2e/remote/features-config.json b/tests/e2e/remote/features-config.json deleted file mode 100644 index 876a75516d..0000000000 --- a/tests/e2e/remote/features-config.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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 From 9c30e13bed63e4bb34ad7d993552d6a93da49d44 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 18:02:15 +0200 Subject: [PATCH 40/55] fixes --- tests/e2e/docker.web.docker-compose.yml | 1 + tests/e2e/tests/regression/insights/feature-flag.e2e.ts | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 9084f53620..c004158cdc 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -7,6 +7,7 @@ services: dockerfile: static-server.Dockerfile volumes: - ./remote:/app/remote + - ./test-data/features-configs:/app/test-data/features-configs ports: - 5551:5551 diff --git a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index 0f7a88c9dc..f82c97e411 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -94,7 +94,6 @@ test let featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; let versionFromConfig = await Common.getJsonPropertyValue('version', pathes.validConfig); - // Verify that config file updated from the GitHub repository if the GitHub file has the latest timestamp 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'); @@ -104,7 +103,6 @@ test 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); - // Verify that Insights panel displayed if user's controlNumber is in range from config file await t.expect(browserPage.InsightsPanel.getRecommendationByName(redisVersionRecom).exists).ok('Redis Version recommendation not displayed'); await browserPage.InsightsPanel.toggleInsightsPanel(false); @@ -117,11 +115,11 @@ test // 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 the local config file has it disabled + // Verify that Insights panel displayed if user's controlNumber is out of range from config file await updateControlNumber(30.1); - // Verify that Insights panel can be displayed for Electron/WebStack app according to filters 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 @@ -162,6 +160,5 @@ test // Update remote config .json to config with buildType filter including current app build await modifyFeaturesConfigJson(pathes.electronConfig); await updateControlNumber(48.2); - // Verify that Insights panel can be displayed for Electron/WebStack app according to filters await t.expect(browserPage.InsightsPanel.insightsBtn.exists).ok('Insights panel not displayed when filter includes this buildType'); }); From dbf28c17559c2ec4b49e5a9ca17fd7b76e47386e Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 18:59:50 +0200 Subject: [PATCH 41/55] fix for fs feature config sync --- tests/e2e/docker.web.docker-compose.yml | 1 - tests/e2e/helpers/insights.ts | 6 ++++-- tests/e2e/static.ts | 4 ---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index c004158cdc..9084f53620 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -7,7 +7,6 @@ services: dockerfile: static-server.Dockerfile volumes: - ./remote:/app/remote - - ./test-data/features-configs:/app/test-data/features-configs ports: - 5551:5551 diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index da43790948..6a3709d83b 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import * as fs from 'fs-extra'; import * as path from 'path'; import { BasePage } from '../pageObjects'; import { deleteRowsFromTableInDB, updateColumnValueInDBTable } from './database-scripts'; @@ -13,10 +13,12 @@ const basePage = new BasePage(); 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.writeFileSync(path.join(remoteConfigPath, configFileName), fs.readFileSync(filePath)); + fs.ensureFileSync(targetFilePath); + fs.writeFileSync(targetFilePath, fs.readFileSync(filePath)); resolve(); } catch (err) { diff --git a/tests/e2e/static.ts b/tests/e2e/static.ts index a70a044b16..879ef3ebe7 100644 --- a/tests/e2e/static.ts +++ b/tests/e2e/static.ts @@ -1,8 +1,4 @@ const express = require('express'); -const fs = require('fs-extra'); - -fs.mkdirSync('./remote', { recursive: true }); -fs.copyFileSync('./test-data/features-configs/insights-default-remote.json', './remote/features-config.json'); const app = express(); app.use('/remote', express.static('./remote')); From 22c426249cbf398be4e7f8ecd3a85bbe72b4de1c Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 19:21:09 +0200 Subject: [PATCH 42/55] add fs-extra --- tests/e2e/package.json | 2 + .../regression/insights/feature-flag.e2e.ts | 50 ++---- tests/e2e/yarn.lock | 158 +++++++++++++++++- 3 files changed, 176 insertions(+), 34 deletions(-) diff --git a/tests/e2e/package.json b/tests/e2e/package.json index cd199ffb3f..05bed6c576 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -35,6 +35,7 @@ "@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", @@ -47,6 +48,7 @@ "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/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index f82c97e411..d75984f59d 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -32,46 +32,32 @@ fixture.only `Feature flag` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async() => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); - }) - .afterEach(async() => { - // Delete database - await deleteStandaloneDatabaseApi(ossStandaloneConfig); - }); -test - .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - // Update remote config .json to default await refreshFeaturesTestData(); }) - .after(async() => { + .afterEach(async() => { + // Delete database await deleteStandaloneDatabaseApi(ossStandaloneV5Config); await refreshFeaturesTestData(); - })('Verify that default config applied when remote config version is lower', async t => { - const featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; - - await updateControlNumber(19.2); - 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 - .before(async() => { - await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - await refreshFeaturesTestData(); - }) - .after(async() => { - await deleteStandaloneDatabaseApi(ossStandaloneV5Config); - await refreshFeaturesTestData(); - })('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); +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; + 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'); - }); + 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); diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock index f989268e12..7f8bb98b30 100644 --- a/tests/e2e/yarn.lock +++ b/tests/e2e/yarn.lock @@ -1044,6 +1044,16 @@ dependencies: stackframe "^1.1.1" +"@electron/asar@^3.2.3": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.2.4.tgz#7e8635a3c4f6d8b3f8ae6efaf5ecb9fbf3bd9864" + integrity sha512-lykfY3TJRRWFeTxccEKdf1I6BLl2Plw81H0bbp4Fc5iEc67foDCa5pjJQULVgo0wF+Dli75f3xVcdb/67FFZ/g== + dependencies: + chromium-pickle-js "^0.2.0" + commander "^5.0.0" + glob "^7.1.6" + minimatch "^3.0.4" + "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -1255,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" @@ -1273,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" @@ -1403,6 +1428,13 @@ acorn-hammerhead@0.4.0: dependencies: "@types/estree" "0.0.46" +acorn-hammerhead@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/acorn-hammerhead/-/acorn-hammerhead-0.6.1.tgz#f8f27c58ceaf90fbdb77a92f4331a678271194f2" + integrity sha512-ZWG/nXPvFiveXhJq/PxuS+4LI1BqtEOviGXWjlTvI+64kwzaddYNaE0UzLorTX7kyxrFtxjJ4w1LmKN5yEzOCg== + dependencies: + "@types/estree" "0.0.46" + acorn-jsx@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -2122,6 +2154,11 @@ commander@^2.20.0, commander@^2.8.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" + integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== + component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -2376,6 +2413,14 @@ depd@^2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +des.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" + integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA== + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + detect-libc@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" @@ -2730,6 +2775,13 @@ esotope-hammerhead@0.6.1: dependencies: "@types/estree" "0.0.46" +esotope-hammerhead@0.6.4: + version "0.6.4" + resolved "https://registry.yarnpkg.com/esotope-hammerhead/-/esotope-hammerhead-0.6.4.tgz#e3b8ae5fbe5954aafc5bf507b8399560500be8b0" + integrity sha512-QY4HXqvjLSFGoGgHvm3H1QUMNcpwnUpGRBaVVFWE5uqbPQh9HSWcA1YD7KwwL/IrgerDwZn00z5dtYT9Ot/C/A== + dependencies: + "@types/estree" "0.0.46" + espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" @@ -3030,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" @@ -3249,7 +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.0, graceful-fs@^4.2.2, graceful-fs@^4.2.6: +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== @@ -3371,6 +3432,21 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" +httpntlm@^1.8.10: + version "1.8.12" + resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.8.12.tgz#1b2f46e9839cc8434cced1b120d8689fdbd19387" + integrity sha512-dqdye5b5OmzCIDrA2JgkKG7bV9sK0S5VUELD1+JcRZG6ZDieAW7/c0MPsqlTRKDzso1tIMhvDQAWvfgFN0yg3A== + dependencies: + des.js "^1.0.1" + httpreq ">=0.4.22" + js-md4 "^0.3.2" + underscore "~1.12.1" + +httpreq@>=0.4.22: + version "0.5.2" + resolved "https://registry.yarnpkg.com/httpreq/-/httpreq-0.5.2.tgz#be6777292fa1038d7771d7c01d9a5e1219de951c" + integrity sha512-2Jm+x9WkExDOeFRrdBCBSpLPT5SokTcRHkunV3pjKmX/cx6av8zQ0WtHUMDrYb6O4hBFzNU6sxJEypvRUVYKnw== + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -3832,6 +3908,11 @@ iterate-object@^1.3.4: resolved "https://registry.yarnpkg.com/iterate-object/-/iterate-object-1.3.4.tgz#fa50b1d9e58e340a7dd6b4c98c8a5e182e790096" integrity sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw== +js-md4@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" + integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== + js-message@1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/js-message/-/js-message-1.0.7.tgz#fbddd053c7a47021871bb8b2c95397cc17c20e47" @@ -3909,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" @@ -4201,6 +4291,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.4, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -4838,6 +4933,11 @@ proxyquire@^1.7.10: module-not-found-error "^1.0.0" resolve "~1.1.7" +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -4851,7 +4951,7 @@ punycode@^1.4.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.3.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== @@ -5654,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" @@ -5878,6 +6008,15 @@ tough-cookie@2.3.3: dependencies: punycode "^1.4.1" +tough-cookie@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.1.2" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -5999,6 +6138,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +underscore@~1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.1.tgz#7bb8cc9b3d397e201cf8553336d262544ead829e" + integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -6046,6 +6190,16 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" +universalify@^0.1.2: + version "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" From 347cf8060f8ad24e039542f05e1db11c8736ec60 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 20:02:53 +0200 Subject: [PATCH 43/55] add logs for debug --- tests/e2e/helpers/insights.ts | 1 + tests/e2e/tests/regression/insights/feature-flag.e2e.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 6a3709d83b..5552225b9a 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -19,6 +19,7 @@ export async function modifyFeaturesConfigJson(filePath: string): Promise try { fs.ensureFileSync(targetFilePath); fs.writeFileSync(targetFilePath, fs.readFileSync(filePath)); + console.log(`Features config modified: ${fs.readFileSync(targetFilePath)}`); resolve(); } catch (err) { diff --git a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index d75984f59d..8b02551f44 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -77,6 +77,8 @@ test // Update remote config .json to valid await modifyFeaturesConfigJson(pathes.validConfig); await updateControlNumber(48.2); + await console.log(await getColumnValueFromTableInDB(featuresConfigTable, 'data')); + await console.log(await getColumnValueFromTableInDB(featuresConfigTable, 'controlNumber')); let featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; let versionFromConfig = await Common.getJsonPropertyValue('version', pathes.validConfig); From 0bec7847aec5ede8c79521bf812fda3e5432e7f5 Mon Sep 17 00:00:00 2001 From: Artem Date: Tue, 23 May 2023 22:26:36 +0300 Subject: [PATCH 44/55] #RI-4399 fix tests --- tests/e2e/.env | 1 - tests/e2e/docker.web.docker-compose.yml | 1 + tests/e2e/local.web.docker-compose.yml | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/.env b/tests/e2e/.env index f5f61fb831..b86cfd812c 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -6,4 +6,3 @@ NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/ NOTIFICATION_SYNC_INTERVAL=30000 RI_FEATURES_CONFIG_URL=http://static-server:5551/remote/features-config.json RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 -REMOTE_FOLDER_PATH=./remote diff --git a/tests/e2e/docker.web.docker-compose.yml b/tests/e2e/docker.web.docker-compose.yml index 9084f53620..63f3aba353 100644 --- a/tests/e2e/docker.web.docker-compose.yml +++ b/tests/e2e/docker.web.docker-compose.yml @@ -36,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', '--', diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index a7b8d9aefa..2335898771 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -33,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', ] From 72acec1210f4d7b85f909a52514206b5b8bc584d Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Tue, 23 May 2023 21:50:21 +0200 Subject: [PATCH 45/55] return parallelism and updates for live recommendations --- .circleci/config.yml | 2 +- tests/e2e/.desktop.env | 3 +- tests/e2e/.env | 2 +- tests/e2e/helpers/insights.ts | 1 - .../regression/insights/feature-flag.e2e.ts | 4 +- .../insights/live-recommendations.e2e.ts | 46 ++++++++++++++----- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c5f88536a7..8ecee1ea94 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1014,7 +1014,7 @@ workflows: - e2e-tests: name: E2ETest build: docker - parallelism: 1 + parallelism: 4 requires: - Build docker image # Workflow for feature, bugfix, main branches diff --git a/tests/e2e/.desktop.env b/tests/e2e/.desktop.env index 11b058ebbe..c5d5989d07 100644 --- a/tests/e2e/.desktop.env +++ b/tests/e2e/.desktop.env @@ -28,5 +28,4 @@ NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/ NOTIFICATION_SYNC_INTERVAL=30000 RI_FEATURES_CONFIG_URL=http://localhost:5551/remote/features-config.json -RI_FEATURES_CONFIG_SYNC_INTERVAL=5000 -REMOTE_FOLDER_PATH=./remote +RI_FEATURES_CONFIG_SYNC_INTERVAL=50000 diff --git a/tests/e2e/.env b/tests/e2e/.env index b86cfd812c..9e354b1b99 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -5,4 +5,4 @@ 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=5000 +RI_FEATURES_CONFIG_SYNC_INTERVAL=50000 diff --git a/tests/e2e/helpers/insights.ts b/tests/e2e/helpers/insights.ts index 5552225b9a..6a3709d83b 100644 --- a/tests/e2e/helpers/insights.ts +++ b/tests/e2e/helpers/insights.ts @@ -19,7 +19,6 @@ export async function modifyFeaturesConfigJson(filePath: string): Promise try { fs.ensureFileSync(targetFilePath); fs.writeFileSync(targetFilePath, fs.readFileSync(filePath)); - console.log(`Features config modified: ${fs.readFileSync(targetFilePath)}`); resolve(); } catch (err) { diff --git a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index 8b02551f44..d05ad8f783 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -28,7 +28,7 @@ const pathes = { electronConfig: path.join('.', 'test-data', 'features-configs', 'insights-electron-build.json') }; -fixture.only `Feature flag` +fixture `Feature flag` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async() => { @@ -77,8 +77,6 @@ test // Update remote config .json to valid await modifyFeaturesConfigJson(pathes.validConfig); await updateControlNumber(48.2); - await console.log(await getColumnValueFromTableInDB(featuresConfigTable, 'data')); - await console.log(await getColumnValueFromTableInDB(featuresConfigTable, 'controlNumber')); let featureVersion = await JSON.parse(await getColumnValueFromTableInDB(featuresConfigTable, 'data')).version; let versionFromConfig = await Common.getJsonPropertyValue('version', pathes.validConfig); diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 9fd8fbedf9..04a2c91ab8 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -1,5 +1,6 @@ -import { BrowserPage, MemoryEfficiencyPage, MyRedisDatabasePage, SettingsPage, WorkbenchPage } from '../../../pageObjects'; -import { RecommendationIds, rte, env } from '../../../helpers/constants'; +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 { @@ -10,6 +11,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,36 +35,45 @@ const expectedProperties = [ 'provider', 'vote' ]; +const featuresConfig = path.join('.', 'test-data', 'features-configs', 'insights-analytics-filter-off.json'); const redisVersionRecom = RecommendationIds.redisVersion; const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; const setPasswordRecom = RecommendationIds.setPassword; -fixture`Live Recommendations` +fixture `Live Recommendations` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) - .beforeEach(async () => { + .beforeEach(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); }) - .afterEach(async () => { + .afterEach(async() => { // Delete database await deleteStandaloneDatabaseApi(ossStandaloneConfig); + await refreshFeaturesTestData(); }); test - .before(async () => { + .before(async() => { // Add new databases using API await acceptLicenseTerms(); await addNewStandaloneDatabasesApi(databasesForAdding); // Reload Page await myRedisDatabasePage.reloadPage(); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); }) - .after(async () => { + .after(async() => { // Clear and delete database await browserPage.InsightsPanel.toggleInsightsPanel(false); await browserPage.OverviewPanel.changeDbIndex(0); await browserPage.deleteKeyByName(keyName); await deleteStandaloneDatabasesApi(databasesForAdding); + await refreshFeaturesTestData(); })('Verify Insights panel Recommendations displaying', async t => { await browserPage.InsightsPanel.toggleInsightsPanel(true); // Verify that "Welcome to recommendations" panel displayed when there are no recommendations @@ -92,10 +103,14 @@ test }); test .requestHooks(logger) - .before(async () => { + .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - }).after(async () => { + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); + }).after(async() => { await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + await refreshFeaturesTestData(); })('Verify that user can upvote recommendations', async() => { const notUsefulVoteOption = 'not useful'; const usefulVoteOption = 'useful'; @@ -165,10 +180,14 @@ test('Verify that user can snooze recommendation', async t => { await t.expect(await browserPage.InsightsPanel.getRecommendationByName(searchVisualizationRecom).visible).ok('recommendation is not displayed again'); }); test - .before(async () => { + .before(async() => { await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); - }).after(async () => { + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); + }).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); @@ -205,9 +224,12 @@ 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 () => { + .after(async() => { await browserPage.deleteKeyByName(keyName); await deleteStandaloneDatabasesApi(databasesForAdding); + await refreshFeaturesTestData(); + await modifyFeaturesConfigJson(featuresConfig); + await updateControlNumber(47.2); })('Verify that key name is displayed for Insights and DA recommendations', async t => { const cliCommand = `JSON.SET ${keyName} $ '{ "model": "Hyperion", "brand": "Velorim"}'`; await browserPage.Cli.sendCommandInCli(cliCommand); From 6ef9fd227819d65ddbcb9ef92ab719fde863845d Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 24 May 2023 09:46:40 +0200 Subject: [PATCH 46/55] fix for live recommendations --- tests/e2e/tests/regression/insights/live-recommendations.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index 04a2c91ab8..bbff95ad55 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -35,7 +35,7 @@ const expectedProperties = [ 'provider', 'vote' ]; -const featuresConfig = path.join('.', 'test-data', 'features-configs', 'insights-analytics-filter-off.json'); +const featuresConfig = path.join('.', 'test-data', 'features-configs', 'insights-valid.json'); const redisVersionRecom = RecommendationIds.redisVersion; const redisTimeSeriesRecom = RecommendationIds.optimizeTimeSeries; const searchVisualizationRecom = RecommendationIds.searchVisualization; From 8bf78959a674c5454e691acb7a6282f96ad2de8e Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 11:41:49 +0300 Subject: [PATCH 47/55] #RI-4399 fix UTests + ITests (code) --- .../modules/bulk-actions/models/bulk-action.spec.ts | 2 +- ...abases-id-bulk_actions-import-tutorial_data.test.ts | 4 ++-- .../POST-databases-id-bulk_actions-import.test.ts | 10 +++++----- .../POST-databases-id-redisearch-search.test.ts | 1 + 4 files changed, 9 insertions(+), 8 deletions(-) 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/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/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); From e2f6906e67d408d805bb2a4d6c119bd62b633524 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 12:03:50 +0300 Subject: [PATCH 48/55] #RI-4399 fix recommendations ITests (todo: investigate false vs undefined) --- .../WS-new-recommendations.test.ts | 13 ++++++++----- redisinsight/api/test/helpers/local-db.ts | 8 ++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) 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/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 77d24b1e99..051987af27 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -542,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); From e144bf62e7c8f506d4ef42787cb8bbaacf87734f Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 12:27:33 +0300 Subject: [PATCH 49/55] run all tests to verify --- .circleci/config.yml | 180 +++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8ecee1ea94..e527e7c520 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -979,23 +979,23 @@ workflows: - /^fe/feature.*/ - /^fe/bugfix.*/ # BE Unit + Integration (limited RTEs) tests for "be/feature" or "be/bugfix" branches only - backend-tests: - jobs: - - unit-tests-api: - name: UTest - API - filters: - branches: - only: - - /^be/feature.*/ - - /^be/bugfix.*/ - - integration-tests-run: - matrix: - alias: itest-code - parameters: - rte: *iTestsNamesShort - name: ITest - << matrix.rte >> (code) - requires: - - UTest - API +# backend-tests: +# jobs: +# - unit-tests-api: +# name: UTest - API +# filters: +# branches: +# only: +# - /^be/feature.*/ +# - /^be/bugfix.*/ +# - integration-tests-run: +# matrix: +# alias: itest-code +# parameters: +# rte: *iTestsNamesShort +# name: ITest - << matrix.rte >> (code) +# requires: +# - UTest - API # E2E tests for "e2e/feature" or "e2e/bugfix" branches only e2e-tests: jobs: @@ -1024,12 +1024,12 @@ workflows: - approve: name: Start All Tests type: approval - filters: - branches: - only: - - /^feature.*/ - - /^bugfix.*/ - - main +# filters: +# branches: +# only: +# - /^feature.*/ +# - /^bugfix.*/ +# - main # FE tests - unit-tests-ui: name: UTest - UI @@ -1288,28 +1288,28 @@ workflows: <<: *prodFilter # double check for "latest" # Nightly tests nightly: - triggers: - - schedule: - cron: '0 0 * * *' - filters: - branches: - only: - - main +# triggers: +# - schedule: +# cron: '0 0 * * *' +# filters: +# branches: +# only: +# - main jobs: # build docker image - docker: name: Build docker image # build desktop app - - setup-sign-certificates: - name: Setup sign certificates (stage) - - setup-build: - name: Setup build (stage) - requires: - - Setup sign certificates (stage) - - linux: - name: Build app - Linux (stage) - requires: - - Setup build (stage) +# - setup-sign-certificates: +# name: Setup sign certificates (stage) +# - setup-build: +# name: Setup build (stage) +# requires: +# - Setup sign certificates (stage) +# - linux: +# name: Build app - Linux (stage) +# requires: +# - Setup build (stage) # - windows: # name: Build app - Windows (stage) # requires: @@ -1326,56 +1326,56 @@ workflows: requires: - Build docker image # e2e web tests on docker image build - - e2e-tests: - name: E2ETest - Nightly - parallelism: 4 - build: docker - report: true - requires: - - Build docker image - # e2e desktop tests on AppImage build - - e2e-app-image: - name: E2ETest (AppImage) - Nightly - parallelism: 2 - report: true - requires: - - Build app - Linux (stage) - - - virustotal-url: - name: Virus check - AppImage (nightly) - fileName: RedisInsight-v2-linux-x86_64.AppImage - - virustotal-url: - name: Virus check - deb (nightly) - fileName: RedisInsight-v2-linux-amd64.deb - - virustotal-url: - name: Virus check - rpm (nightly) - fileName: RedisInsight-v2-linux-x86_64.rpm - - virustotal-url: - name: Virus check - snap (nightly) - fileName: RedisInsight-v2-linux-amd64.snap - - virustotal-url: - name: Virus check x64 - dmg (nightly) - fileName: RedisInsight-v2-mac-x64.dmg - - virustotal-url: - name: Virus check arm64 - dmg (nightly) - fileName: RedisInsight-v2-mac-arm64.dmg - - virustotal-url: - name: Virus check MAS - pkg (nightly) - fileName: RedisInsight-mac-universal-mas.pkg - - virustotal-url: - name: Virus check - exe (nightly) - fileName: RedisInsight-v2-win-installer.exe - - virustotal-report: - name: Virus check report (prod) - requires: - - Virus check - AppImage (nightly) - - Virus check - deb (nightly) - - Virus check - rpm (nightly) - - Virus check - snap (nightly) - - Virus check x64 - dmg (nightly) - - Virus check arm64 - dmg (nightly) - - Virus check MAS - pkg (nightly) - - Virus check - exe (nightly) +# - e2e-tests: +# name: E2ETest - Nightly +# parallelism: 4 +# build: docker +# report: true +# requires: +# - Build docker image +# # e2e desktop tests on AppImage build +# - e2e-app-image: +# name: E2ETest (AppImage) - Nightly +# parallelism: 2 +# report: true +# requires: +# - Build app - Linux (stage) +# +# - virustotal-url: +# name: Virus check - AppImage (nightly) +# fileName: RedisInsight-v2-linux-x86_64.AppImage +# - virustotal-url: +# name: Virus check - deb (nightly) +# fileName: RedisInsight-v2-linux-amd64.deb +# - virustotal-url: +# name: Virus check - rpm (nightly) +# fileName: RedisInsight-v2-linux-x86_64.rpm +# - virustotal-url: +# name: Virus check - snap (nightly) +# fileName: RedisInsight-v2-linux-amd64.snap +# - virustotal-url: +# name: Virus check x64 - dmg (nightly) +# fileName: RedisInsight-v2-mac-x64.dmg +# - virustotal-url: +# name: Virus check arm64 - dmg (nightly) +# fileName: RedisInsight-v2-mac-arm64.dmg +# - virustotal-url: +# name: Virus check MAS - pkg (nightly) +# fileName: RedisInsight-mac-universal-mas.pkg +# - virustotal-url: +# name: Virus check - exe (nightly) +# fileName: RedisInsight-v2-win-installer.exe +# - virustotal-report: +# name: Virus check report (prod) +# requires: +# - Virus check - AppImage (nightly) +# - Virus check - deb (nightly) +# - Virus check - rpm (nightly) +# - Virus check - snap (nightly) +# - Virus check x64 - dmg (nightly) +# - Virus check arm64 - dmg (nightly) +# - Virus check MAS - pkg (nightly) +# - Virus check - exe (nightly) # # e2e desktop tests on exe build # - e2e-exe: From 6feebe4311a76a1569a71e078932240b4488e71f Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 24 May 2023 11:54:02 +0200 Subject: [PATCH 50/55] fix for live rec --- .../insights/live-recommendations.e2e.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts index bbff95ad55..bf43433c69 100644 --- a/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/regression/insights/live-recommendations.e2e.ts @@ -4,6 +4,7 @@ import { RecommendationIds, rte } from '../../../helpers/constants'; import { acceptLicenseTerms, acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../helpers/conf'; import { + addNewStandaloneDatabaseApi, addNewStandaloneDatabasesApi, deleteStandaloneDatabaseApi, deleteStandaloneDatabasesApi @@ -45,35 +46,38 @@ 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); - await refreshFeaturesTestData(); }); 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(); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); - await refreshFeaturesTestData(); - await modifyFeaturesConfigJson(featuresConfig); - await updateControlNumber(47.2); }) .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); - await refreshFeaturesTestData(); })('Verify Insights panel Recommendations displaying', async t => { await browserPage.InsightsPanel.toggleInsightsPanel(true); // Verify that "Welcome to recommendations" panel displayed when there are no recommendations @@ -104,13 +108,16 @@ 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 deleteStandaloneDatabaseApi(ossStandaloneV5Config); await refreshFeaturesTestData(); + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); })('Verify that user can upvote recommendations', async() => { const notUsefulVoteOption = 'not useful'; const usefulVoteOption = 'useful'; @@ -181,10 +188,13 @@ 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(); @@ -225,11 +235,9 @@ 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); - await refreshFeaturesTestData(); - await modifyFeaturesConfigJson(featuresConfig); - await updateControlNumber(47.2); })('Verify that key name is displayed for Insights and DA recommendations', async t => { const cliCommand = `JSON.SET ${keyName} $ '{ "model": "Hyperion", "brand": "Velorim"}'`; await browserPage.Cli.sendCommandInCli(cliCommand); From 278d33210133e58889cd77e7af6dc468296bcb93 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 24 May 2023 13:03:24 +0200 Subject: [PATCH 51/55] fixes for failed tests --- tests/e2e/pageObjects/dialogs/onboarding-cards-dialog.ts | 4 ++-- .../critical-path/memory-efficiency/recommendations.e2e.ts | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) 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/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 => { From 956d857bc2ecee7557312c417f0ba29415dead64 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 14:05:31 +0300 Subject: [PATCH 52/55] fix unkown aliases (probably docker DNS conf) --- redisinsight/api/test/test-runs/start-test-run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 \ From c26330b23d562979d94cc4f84094da95830e6926 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 14:41:52 +0300 Subject: [PATCH 53/55] rollback circleci config --- .circleci/config.yml | 180 +++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e527e7c520..8ecee1ea94 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -979,23 +979,23 @@ workflows: - /^fe/feature.*/ - /^fe/bugfix.*/ # BE Unit + Integration (limited RTEs) tests for "be/feature" or "be/bugfix" branches only -# backend-tests: -# jobs: -# - unit-tests-api: -# name: UTest - API -# filters: -# branches: -# only: -# - /^be/feature.*/ -# - /^be/bugfix.*/ -# - integration-tests-run: -# matrix: -# alias: itest-code -# parameters: -# rte: *iTestsNamesShort -# name: ITest - << matrix.rte >> (code) -# requires: -# - UTest - API + backend-tests: + jobs: + - unit-tests-api: + name: UTest - API + filters: + branches: + only: + - /^be/feature.*/ + - /^be/bugfix.*/ + - integration-tests-run: + matrix: + alias: itest-code + parameters: + rte: *iTestsNamesShort + name: ITest - << matrix.rte >> (code) + requires: + - UTest - API # E2E tests for "e2e/feature" or "e2e/bugfix" branches only e2e-tests: jobs: @@ -1024,12 +1024,12 @@ workflows: - approve: name: Start All Tests type: approval -# filters: -# branches: -# only: -# - /^feature.*/ -# - /^bugfix.*/ -# - main + filters: + branches: + only: + - /^feature.*/ + - /^bugfix.*/ + - main # FE tests - unit-tests-ui: name: UTest - UI @@ -1288,28 +1288,28 @@ workflows: <<: *prodFilter # double check for "latest" # Nightly tests nightly: -# triggers: -# - schedule: -# cron: '0 0 * * *' -# filters: -# branches: -# only: -# - main + triggers: + - schedule: + cron: '0 0 * * *' + filters: + branches: + only: + - main jobs: # build docker image - docker: name: Build docker image # build desktop app -# - setup-sign-certificates: -# name: Setup sign certificates (stage) -# - setup-build: -# name: Setup build (stage) -# requires: -# - Setup sign certificates (stage) -# - linux: -# name: Build app - Linux (stage) -# requires: -# - Setup build (stage) + - setup-sign-certificates: + name: Setup sign certificates (stage) + - setup-build: + name: Setup build (stage) + requires: + - Setup sign certificates (stage) + - linux: + name: Build app - Linux (stage) + requires: + - Setup build (stage) # - windows: # name: Build app - Windows (stage) # requires: @@ -1326,56 +1326,56 @@ workflows: requires: - Build docker image # e2e web tests on docker image build -# - e2e-tests: -# name: E2ETest - Nightly -# parallelism: 4 -# build: docker -# report: true -# requires: -# - Build docker image -# # e2e desktop tests on AppImage build -# - e2e-app-image: -# name: E2ETest (AppImage) - Nightly -# parallelism: 2 -# report: true -# requires: -# - Build app - Linux (stage) -# -# - virustotal-url: -# name: Virus check - AppImage (nightly) -# fileName: RedisInsight-v2-linux-x86_64.AppImage -# - virustotal-url: -# name: Virus check - deb (nightly) -# fileName: RedisInsight-v2-linux-amd64.deb -# - virustotal-url: -# name: Virus check - rpm (nightly) -# fileName: RedisInsight-v2-linux-x86_64.rpm -# - virustotal-url: -# name: Virus check - snap (nightly) -# fileName: RedisInsight-v2-linux-amd64.snap -# - virustotal-url: -# name: Virus check x64 - dmg (nightly) -# fileName: RedisInsight-v2-mac-x64.dmg -# - virustotal-url: -# name: Virus check arm64 - dmg (nightly) -# fileName: RedisInsight-v2-mac-arm64.dmg -# - virustotal-url: -# name: Virus check MAS - pkg (nightly) -# fileName: RedisInsight-mac-universal-mas.pkg -# - virustotal-url: -# name: Virus check - exe (nightly) -# fileName: RedisInsight-v2-win-installer.exe -# - virustotal-report: -# name: Virus check report (prod) -# requires: -# - Virus check - AppImage (nightly) -# - Virus check - deb (nightly) -# - Virus check - rpm (nightly) -# - Virus check - snap (nightly) -# - Virus check x64 - dmg (nightly) -# - Virus check arm64 - dmg (nightly) -# - Virus check MAS - pkg (nightly) -# - Virus check - exe (nightly) + - e2e-tests: + name: E2ETest - Nightly + parallelism: 4 + build: docker + report: true + requires: + - Build docker image + # e2e desktop tests on AppImage build + - e2e-app-image: + name: E2ETest (AppImage) - Nightly + parallelism: 2 + report: true + requires: + - Build app - Linux (stage) + + - virustotal-url: + name: Virus check - AppImage (nightly) + fileName: RedisInsight-v2-linux-x86_64.AppImage + - virustotal-url: + name: Virus check - deb (nightly) + fileName: RedisInsight-v2-linux-amd64.deb + - virustotal-url: + name: Virus check - rpm (nightly) + fileName: RedisInsight-v2-linux-x86_64.rpm + - virustotal-url: + name: Virus check - snap (nightly) + fileName: RedisInsight-v2-linux-amd64.snap + - virustotal-url: + name: Virus check x64 - dmg (nightly) + fileName: RedisInsight-v2-mac-x64.dmg + - virustotal-url: + name: Virus check arm64 - dmg (nightly) + fileName: RedisInsight-v2-mac-arm64.dmg + - virustotal-url: + name: Virus check MAS - pkg (nightly) + fileName: RedisInsight-mac-universal-mas.pkg + - virustotal-url: + name: Virus check - exe (nightly) + fileName: RedisInsight-v2-win-installer.exe + - virustotal-report: + name: Virus check report (prod) + requires: + - Virus check - AppImage (nightly) + - Virus check - deb (nightly) + - Virus check - rpm (nightly) + - Virus check - snap (nightly) + - Virus check x64 - dmg (nightly) + - Virus check arm64 - dmg (nightly) + - Virus check MAS - pkg (nightly) + - Virus check - exe (nightly) # # e2e desktop tests on exe build # - e2e-exe: From 9c7640dbf4cc90d1df5c55c1d2b2c3121fd52e57 Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 24 May 2023 15:35:16 +0300 Subject: [PATCH 54/55] #RI-4568 fix number type to be float in local db for conrtolNumber --- .../{1684175820824-feature.ts => 1684931530343-feature.ts} | 6 +++--- redisinsight/api/migration/index.ts | 4 ++-- .../src/modules/feature/entities/features-config.entity.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename redisinsight/api/migration/{1684175820824-feature.ts => 1684931530343-feature.ts} (67%) diff --git a/redisinsight/api/migration/1684175820824-feature.ts b/redisinsight/api/migration/1684931530343-feature.ts similarity index 67% rename from redisinsight/api/migration/1684175820824-feature.ts rename to redisinsight/api/migration/1684931530343-feature.ts index c4cd8c3a6e..5c1f31ecf2 100644 --- a/redisinsight/api/migration/1684175820824-feature.ts +++ b/redisinsight/api/migration/1684931530343-feature.ts @@ -1,11 +1,11 @@ import { MigrationInterface, QueryRunner } from "typeorm"; -export class feature1684175820824 implements MigrationInterface { - name = 'feature1684175820824' +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" integer, "data" varchar NOT NULL, "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`); + 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 { diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 96a21072c7..11ae966a9d 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -31,7 +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 { feature1684175820824 } from './1684175820824-feature'; +import { Feature1684931530343 } from './1684931530343-feature'; export default [ initialMigration1614164490968, @@ -67,5 +67,5 @@ export default [ customTutorials1677135091633, databaseRecommendations1681900503586, databaseRecommendationParams1683006064293, - feature1684175820824, + Feature1684931530343, ]; diff --git a/redisinsight/api/src/modules/feature/entities/features-config.entity.ts b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts index 08184277ef..706113f9d1 100644 --- a/redisinsight/api/src/modules/feature/entities/features-config.entity.ts +++ b/redisinsight/api/src/modules/feature/entities/features-config.entity.ts @@ -10,7 +10,7 @@ export class FeaturesConfigEntity { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: true }) + @Column({ nullable: true, type: 'float' }) @Expose() controlNumber: number; From e1490594a7e6fb57ce208d9c4b64f10c49c086f9 Mon Sep 17 00:00:00 2001 From: vlad-dargel Date: Wed, 24 May 2023 16:43:09 +0200 Subject: [PATCH 55/55] wording fix and some unstable tests --- tests/e2e/helpers/pub-sub.ts | 4 ++-- tests/e2e/tests/regression/insights/feature-flag.e2e.ts | 2 +- tests/e2e/wait-for-it.sh | 2 +- tests/e2e/wait-for-redis.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/tests/regression/insights/feature-flag.e2e.ts b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts index d05ad8f783..7a225792a5 100644 --- a/tests/e2e/tests/regression/insights/feature-flag.e2e.ts +++ b/tests/e2e/tests/regression/insights/feature-flag.e2e.ts @@ -104,7 +104,7 @@ test // 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 displayed if user's controlNumber is out of range from config file + // 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'); 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