From 32e1ad44ed48e1aec701cc24d02e2736686f96fa Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Fri, 17 Mar 2023 14:41:59 +0200 Subject: [PATCH] Feat/add cypress tests for project scoped stickiness (#3340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About the changes Closes # ### Important files ## Discussion points --------- Signed-off-by: andreas-unleash --- .../integration/projects/settings.spec.ts | 117 ++++++++++++++++ .../StickinessSelect/StickinessSelect.tsx | 5 +- .../Project/CreateProject/CreateProject.tsx | 15 +- .../Project/EditProject/EditProject.tsx | 7 +- .../src/component/project/Project/Project.tsx | 4 + .../Project/ProjectForm/ProjectForm.tsx | 24 +++- .../project/Project/hooks/useProjectForm.ts | 25 ++-- .../project/ProjectList/ProjectList.tsx | 3 + .../actions/useProjectApi/useProjectApi.ts | 11 +- .../src/hooks/useDefaultProjectSettings.ts | 15 +- src/lib/db/project-store.ts | 41 +++++- src/lib/openapi/index.ts | 2 - .../openapi/spec/health-overview-schema.ts | 7 + src/lib/openapi/spec/index.ts | 1 - .../openapi/spec/project-overview-schema.ts | 7 + src/lib/openapi/spec/project-schema.ts | 7 + .../openapi/spec/project-settings-schema.ts | 23 --- src/lib/routes/admin-api/project/index.ts | 99 ------------- src/lib/services/project-schema.ts | 4 + src/lib/services/project-service.ts | 64 ++++++--- src/lib/types/model.ts | 5 + src/lib/types/stores/project-store.ts | 18 +++ .../api/admin/project/projects.e2e.test.ts | 25 +--- .../__snapshots__/openapi.e2e.test.ts.snap | 131 ++++++------------ src/test/fixtures/fake-project-store.ts | 19 +++ 25 files changed, 386 insertions(+), 293 deletions(-) create mode 100644 frontend/cypress/integration/projects/settings.spec.ts delete mode 100644 src/lib/openapi/spec/project-settings-schema.ts diff --git a/frontend/cypress/integration/projects/settings.spec.ts b/frontend/cypress/integration/projects/settings.spec.ts new file mode 100644 index 0000000000..51307231e9 --- /dev/null +++ b/frontend/cypress/integration/projects/settings.spec.ts @@ -0,0 +1,117 @@ +/// + +type UserCredentials = { email: string; password: string }; +const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE')); +const randomId = String(Math.random()).split('.')[1]; +const featureToggleName = `settings-${randomId}`; +const baseUrl = Cypress.config().baseUrl; +let strategyId = ''; +const userName = `settings-user-${randomId}`; +const projectName = `stickiness-project-${randomId}`; + +// Disable all active splash pages by visiting them. +const disableActiveSplashScreens = () => { + cy.visit(`/splash/operators`); +}; + +const disableFeatureStrategiesProdGuard = () => { + localStorage.setItem( + 'useFeatureStrategyProdGuardSettings:v2', + JSON.stringify({ hide: true }) + ); +}; + +describe('notifications', () => { + before(() => { + disableFeatureStrategiesProdGuard(); + disableActiveSplashScreens(); + cy.login(); + }); + + after(() => { + cy.request( + 'DELETE', + `${baseUrl}/api/admin/features/${featureToggleName}` + ); + + cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); + }); + + beforeEach(() => { + cy.login(); + cy.visit(`/projects`); + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + }); + + afterEach(() => { + cy.request('DELETE', `${baseUrl}/api/admin/projects/${projectName}`); + }); + + const createFeature = () => { + cy.get('[data-testid=NAVIGATE_TO_CREATE_FEATURE').click(); + + cy.intercept('POST', `/api/admin/projects/${projectName}/features`).as( + 'createFeature' + ); + + cy.get("[data-testid='CF_NAME_ID'").type(featureToggleName); + cy.get("[data-testid='CF_DESC_ID'").type('hello-world'); + cy.get("[data-testid='CF_CREATE_BTN_ID']").click(); + cy.wait('@createFeature'); + }; + + const createProject = () => { + cy.get('[data-testid=NAVIGATE_TO_CREATE_PROJECT').click(); + + cy.get("[data-testid='PROJECT_ID_INPUT']").type(projectName); + cy.get("[data-testid='PROJECT_NAME_INPUT']").type(projectName); + cy.get("[id='stickiness-select']") + .first() + .click() + .get('[data-testid=SELECT_ITEM_ID-userId') + .first() + .click(); + cy.get("[data-testid='CREATE_PROJECT_BTN']").click(); + }; + + it('should store default project stickiness when creating, retrieve it when editing a project', () => { + createProject(); + + cy.visit(`/projects/${projectName}`); + if (document.querySelector("[data-testid='CLOSE_SPLASH']")) { + cy.get("[data-testid='CLOSE_SPLASH']").click(); + } + cy.get("[data-testid='NAVIGATE_TO_EDIT_PROJECT']").click(); + + //then + cy.get("[id='stickiness-select']") + .first() + .should('have.text', 'userId'); + }); + + it('should respect the default project stickiness when creating a Gradual Rollout Strategy', () => { + createProject(); + createFeature(); + cy.visit( + `/projects/default/features/${featureToggleName}/strategies/create?environmentId=development&strategyName=flexibleRollout` + ); + + //then + cy.get("[id='stickiness-select']") + .first() + .should('have.text', 'userId'); + }); + + it('should respect the default project stickiness when creating a variant', () => { + createProject(); + createFeature(); + + cy.visit(`/projects/default/features/${featureToggleName}/variants`); + + cy.get("[data-testid='EDIT_VARIANTS_BUTTON']").click(); + //then + cy.get('#menu-stickiness').first().should('have.text', 'userId'); + }); +}); diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx index d275ea23c1..a857bacca5 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx @@ -1,5 +1,5 @@ import Select from 'component/common/select'; -import { SelectChangeEvent } from '@mui/material'; +import { SelectChangeEvent, useTheme } from '@mui/material'; import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; const builtInStickinessOptions = [ { key: 'default', label: 'default' }, @@ -23,6 +23,7 @@ export const StickinessSelect = ({ dataTestId, }: IStickinessSelectProps) => { const { context } = useUnleashContext(); + const theme = useTheme(); const resolveStickinessOptions = () => builtInStickinessOptions.concat( @@ -54,7 +55,7 @@ export const StickinessSelect = ({ style={{ width: 'inherit', minWidth: '100%', - marginBottom: '16px', + marginBottom: theme.spacing(2), }} /> ); diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index 9ade2f889b..f2564ed93a 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -11,6 +11,8 @@ import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; import { GO_BACK } from 'constants/navigate'; +const CREATE_PROJECT_BTN = 'CREATE_PROJECT_BTN'; + const CreateProject = () => { const { setToastData, setToastApiError } = useToast(); const { refetchUser } = useAuthUser(); @@ -34,8 +36,7 @@ const CreateProject = () => { errors, } = useProjectForm(); - const { createProject, setDefaultProjectStickiness, loading } = - useProjectApi(); + const { createProject, loading } = useProjectApi(); const handleSubmit = async (e: Event) => { e.preventDefault(); @@ -47,10 +48,6 @@ const CreateProject = () => { const payload = getProjectPayload(); try { await createProject(payload); - setDefaultProjectStickiness( - projectId, - payload.projectStickiness - ); refetchUser(); navigate(`/projects/${projectId}`); setToastData({ @@ -105,7 +102,11 @@ const CreateProject = () => { clearErrors={clearErrors} validateProjectId={validateProjectId} > - + ); diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index dcbc652c56..ae27f4bac0 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -16,6 +16,8 @@ import { Alert } from '@mui/material'; import { GO_BACK } from 'constants/navigate'; import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; +const EDIT_PROJECT_BTN = 'EDIT_PROJECT_BTN'; + const EditProject = () => { const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); @@ -71,9 +73,9 @@ const EditProject = () => { if (validName) { try { await editProject(id, payload); - setDefaultProjectStickiness( + await setDefaultProjectStickiness( projectId, - payload.projectStickiness + payload.defaultStickiness ); refetch(); navigate(`/projects/${id}`); @@ -128,6 +130,7 @@ const EditProject = () => { diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 1233fbd389..ce02a943d8 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -44,6 +44,8 @@ import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi import { ImportModal } from './Import/ImportModal'; import { IMPORT_BUTTON } from 'utils/testIds'; +const NAVIGATE_TO_EDIT_PROJECT = 'NAVIGATE_TO_EDIT_PROJECT'; + export const Project = () => { const projectId = useRequiredPathParam('projectId'); const params = useQueryParams(); @@ -167,6 +169,7 @@ export const Project = () => { } tooltipProps={{ title: 'Edit project' }} data-loading + data-testid={NAVIGATE_TO_EDIT_PROJECT} > @@ -246,6 +249,7 @@ export const Project = () => { label={tab.title} value={tab.path} onClick={() => navigate(tab.path)} + data-testid={`TAB_${tab.title}`} /> ))} diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index 855ab074b0..dec8ef7a2f 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -13,6 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import Select from 'component/common/select'; +import { DefaultStickiness, ProjectMode } from '../hooks/useProjectForm'; interface IProjectForm { projectId: string; @@ -20,8 +21,10 @@ interface IProjectForm { projectDesc: string; projectStickiness?: string; projectMode?: string; - setProjectStickiness?: React.Dispatch>; - setProjectMode?: React.Dispatch>; + setProjectStickiness?: React.Dispatch< + React.SetStateAction + >; + setProjectMode?: React.Dispatch>; setProjectId: React.Dispatch>; setProjectName: React.Dispatch>; setProjectDesc: React.Dispatch>; @@ -33,6 +36,11 @@ interface IProjectForm { validateProjectId: () => void; } +const PROJECT_STICKINESS_SELECT = 'PROJECT_STICKINESS_SELECT'; +const PROJECT_ID_INPUT = 'PROJECT_ID_INPUT'; +const PROJECT_NAME_INPUT = 'PROJECT_NAME_INPUT'; +const PROJECT_DESCRIPTION_INPUT = 'PROJECT_DESCRIPTION_INPUT'; + const ProjectForm: React.FC = ({ children, handleSubmit, @@ -69,6 +77,7 @@ const ProjectForm: React.FC = ({ onFocus={() => clearErrors()} onBlur={validateProjectId} disabled={mode === 'Edit'} + data-testid={PROJECT_ID_INPUT} autoFocus required /> @@ -83,6 +92,7 @@ const ProjectForm: React.FC = ({ error={Boolean(errors.name)} errorText={errors.name} onFocus={() => clearErrors()} + data-testid={PROJECT_NAME_INPUT} required /> @@ -96,6 +106,7 @@ const ProjectForm: React.FC = ({ maxRows={4} value={projectDesc} onChange={e => setProjectDesc(e.target.value)} + data-testid={PROJECT_DESCRIPTION_INPUT} /> = ({ setProjectStickiness && - setProjectStickiness(e.target.value) + setProjectStickiness( + e.target.value as DefaultStickiness + ) } editable /> @@ -133,7 +147,9 @@ const ProjectForm: React.FC = ({ label="Project mode" name="Project mode" onChange={e => { - setProjectMode?.(e.target.value); + setProjectMode?.( + e.target.value as ProjectMode + ); }} options={[ { key: 'open', label: 'open' }, diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 0b0428d62f..d161602562 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -3,22 +3,27 @@ import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi'; import { formatUnknownError } from 'utils/formatUnknownError'; import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings'; +export type ProjectMode = 'open' | 'protected'; +export type DefaultStickiness = 'default' | 'userId' | 'sessionId' | 'random'; + const useProjectForm = ( initialProjectId = '', initialProjectName = '', initialProjectDesc = '', - initialProjectStickiness = 'default', - initialProjectMode = 'open' + initialProjectStickiness: DefaultStickiness = 'default', + initialProjectMode: ProjectMode = 'open' ) => { const [projectId, setProjectId] = useState(initialProjectId); const { defaultStickiness } = useDefaultProjectSettings(projectId); const [projectName, setProjectName] = useState(initialProjectName); const [projectDesc, setProjectDesc] = useState(initialProjectDesc); - const [projectStickiness, setProjectStickiness] = useState( - defaultStickiness || initialProjectStickiness - ); - const [projectMode, setProjectMode] = useState(initialProjectMode); + const [projectStickiness, setProjectStickiness] = + useState( + defaultStickiness || initialProjectStickiness + ); + const [projectMode, setProjectMode] = + useState(initialProjectMode); const [errors, setErrors] = useState({}); const { validateId } = useProjectApi(); @@ -39,12 +44,16 @@ const useProjectForm = ( setProjectMode(initialProjectMode); }, [initialProjectMode]); + useEffect(() => { + setProjectStickiness(initialProjectStickiness); + }, [initialProjectStickiness]); + const getProjectPayload = () => { return { id: projectId, name: projectName, description: projectDesc, - projectStickiness, + defaultStickiness: projectStickiness, mode: projectMode, }; }; @@ -55,7 +64,7 @@ const useProjectForm = ( return false; } try { - await validateId(getProjectPayload()); + await validateId(getProjectPayload().id); return true; } catch (error: unknown) { setErrors(prev => ({ ...prev, id: formatUnknownError(error) })); diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index 032e0a479f..8cff911b14 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -59,6 +59,8 @@ interface ICreateButtonData { endIcon?: React.ReactNode; } +const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT'; + function resolveCreateButtonData( isOss: boolean, hasAccess: boolean @@ -185,6 +187,7 @@ export const ProjectListNew = () => { permission={CREATE_PROJECT} disabled={createButtonData.disabled} tooltipProps={createButtonData.tooltip} + data-testid={NAVIGATE_TO_CREATE_PROJECT} > New project diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 77ad95788a..10b67e9cf9 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -5,6 +5,8 @@ interface ICreatePayload { id: string; name: string; description: string; + mode: 'open' | 'protected'; + defaultStickiness: 'default' | 'userId' | 'sessionId' | 'random'; } interface IAccessesPayload { @@ -33,13 +35,12 @@ const useProjectApi = () => { } }; - const validateId = async (payload: ICreatePayload) => { + const validateId = async (id: ICreatePayload['id']) => { const path = `api/admin/projects/validate`; const req = createRequest(path, { method: 'POST', - body: JSON.stringify(payload), + body: JSON.stringify({ id }), }); - try { const res = await makeRequest(req.caller, req.id); @@ -207,10 +208,10 @@ const useProjectApi = () => { projectId: string, stickiness: string ) => { - const path = `api/admin/projects/${projectId}/stickiness`; + const path = `api/admin/projects/${projectId}/settings`; const req = createRequest(path, { method: 'POST', - body: JSON.stringify({ stickiness }), + body: JSON.stringify({ defaultStickiness: stickiness }), }); return makeRequest(req.caller, req.id); diff --git a/frontend/src/hooks/useDefaultProjectSettings.ts b/frontend/src/hooks/useDefaultProjectSettings.ts index 069df9982f..ce809f4e67 100644 --- a/frontend/src/hooks/useDefaultProjectSettings.ts +++ b/frontend/src/hooks/useDefaultProjectSettings.ts @@ -3,10 +3,14 @@ import { SWRConfiguration } from 'swr'; import { useCallback } from 'react'; import handleErrorResponses from './api/getters/httpErrorResponseHandler'; import { useConditionalSWR } from './api/getters/useConditionalSWR/useConditionalSWR'; +import { + DefaultStickiness, + ProjectMode, +} from 'component/project/Project/hooks/useProjectForm'; -export interface IStickinessResponse { - defaultStickiness?: string; - mode?: string; +export interface ISettingsResponse { + defaultStickiness?: DefaultStickiness; + mode?: ProjectMode; } const DEFAULT_STICKINESS = 'default'; export const useDefaultProjectSettings = ( @@ -18,7 +22,7 @@ export const useDefaultProjectSettings = ( const PATH = `/api/admin/projects/${projectId}/settings`; const { projectScopedStickiness } = uiConfig.flags; - const { data, error, mutate } = useConditionalSWR( + const { data, error, mutate } = useConditionalSWR( Boolean(projectId) && Boolean(projectScopedStickiness), {}, ['useDefaultProjectSettings', PATH], @@ -26,7 +30,8 @@ export const useDefaultProjectSettings = ( options ); - const defaultStickiness = data?.defaultStickiness ?? DEFAULT_STICKINESS; + const defaultStickiness: DefaultStickiness = + data?.defaultStickiness ?? DEFAULT_STICKINESS; const refetch = useCallback(() => { mutate().catch(console.warn); diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index a85bacccae..847b78168f 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -3,24 +3,27 @@ import { Logger, LogProvider } from '../logger'; import NotFoundError from '../error/notfound-error'; import { + DefaultStickiness, IEnvironment, + IFlagResolver, IProject, IProjectWithCount, ProjectMode, -} from '../types/model'; +} from '../types'; import { IProjectHealthUpdate, IProjectInsert, IProjectQuery, + IProjectSettings, + IProjectSettingsRow, IProjectStore, } from '../types/stores/project-store'; -import { DEFAULT_ENV } from '../util/constants'; +import { DEFAULT_ENV } from '../util'; import metricsHelper from '../util/metrics-helper'; import { DB_TIME } from '../metric-events'; import EventEmitter from 'events'; -import { IFlagResolver } from '../types'; -import Raw = Knex.Raw; import { Db } from './db'; +import Raw = Knex.Raw; const COLUMNS = [ 'id', @@ -160,6 +163,7 @@ class ProjectStore implements IProjectStore { memberCount: Number(row.number_of_users) || 0, updatedAt: row.updated_at, mode: 'open', + defaultStickiness: 'default', }; } @@ -202,7 +206,7 @@ class ProjectStore implements IProjectStore { } async create( - project: IProjectInsert & { mode: ProjectMode }, + project: IProjectInsert & IProjectSettings, ): Promise { const row = await this.db(TABLE) .insert(this.fieldToRow(project)) @@ -211,6 +215,7 @@ class ProjectStore implements IProjectStore { .insert({ project: project.id, project_mode: project.mode, + default_stickiness: project.defaultStickiness, }) .returning('*'); return this.mapRow({ ...row[0], ...settingsRow[0] }); @@ -226,6 +231,7 @@ class ProjectStore implements IProjectStore { .where({ project: data.id }) .update({ project_mode: data.mode, + default_stickiness: data.defaultStickiness, }) .returning('*'); } catch (err) { @@ -458,6 +464,24 @@ class ProjectStore implements IProjectStore { return Number(members.count); } + async getProjectSettings(projectId: string): Promise { + const row = await this.db(SETTINGS_TABLE).where({ project: projectId }); + return this.mapSettingsRow(row[0]); + } + + async setProjectSettings( + projectId: string, + defaultStickiness: DefaultStickiness, + mode: ProjectMode, + ): Promise { + await this.db(SETTINGS_TABLE) + .update({ + default_stickiness: defaultStickiness, + project_mode: mode, + }) + .where({ project: projectId }); + } + async count(): Promise { return this.db .from(TABLE) @@ -465,6 +489,13 @@ class ProjectStore implements IProjectStore { .then((res) => Number(res[0].count)); } + mapSettingsRow(row?: IProjectSettingsRow): IProjectSettings { + return { + defaultStickiness: row?.default_stickiness || 'default', + mode: row?.project_mode || 'open', + }; + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types mapLinkRow(row): IEnvironmentProjectLink { return { diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 8c30752558..0324b59a61 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -133,7 +133,6 @@ import { importTogglesSchema, importTogglesValidateSchema, importTogglesValidateItemSchema, - projectSettingsSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -257,7 +256,6 @@ export const schemas = { stateSchema, strategiesSchema, strategySchema, - projectSettingsSchema, tagsBulkAddSchema, tagSchema, tagsSchema, diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 18493549cd..ad1605c109 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -25,6 +25,13 @@ export const healthOverviewSchema = { type: 'string', nullable: true, }, + defaultStickiness: { + type: 'string', + enum: ['default', 'userId', 'sessionId', 'random'], + example: 'userId', + description: + 'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy', + }, mode: { type: 'string', enum: ['open', 'protected'], diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 69c583db10..2c6be98221 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -129,7 +129,6 @@ export * from './project-overview-schema'; export * from './import-toggles-validate-item-schema'; export * from './import-toggles-validate-schema'; export * from './import-toggles-schema'; -export * from './project-settings-schema'; export * from './tags-bulk-add-schema'; export * from './upsert-segment-schema'; export * from './batch-features-schema'; diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 9422107972..118725d586 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -36,6 +36,13 @@ export const projectOverviewSchema = { example: 'DX squad feature release', description: 'Additional information about the project', }, + defaultStickiness: { + type: 'string', + enum: ['default', 'userId', 'sessionId', 'random'], + example: 'userId', + description: + 'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy', + }, mode: { type: 'string', enum: ['open', 'protected'], diff --git a/src/lib/openapi/spec/project-schema.ts b/src/lib/openapi/spec/project-schema.ts index c847dba378..836f1be211 100644 --- a/src/lib/openapi/spec/project-schema.ts +++ b/src/lib/openapi/spec/project-schema.ts @@ -62,6 +62,13 @@ export const projectSchema = { description: 'A mode of the project affecting what actions are possible in this project', }, + defaultStickiness: { + type: 'string', + enum: ['default', 'userId', 'sessionId', 'random'], + example: 'userId', + description: + 'A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy', + }, }, components: {}, } as const; diff --git a/src/lib/openapi/spec/project-settings-schema.ts b/src/lib/openapi/spec/project-settings-schema.ts deleted file mode 100644 index 243a5a283b..0000000000 --- a/src/lib/openapi/spec/project-settings-schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FromSchema } from 'json-schema-to-ts'; - -export const projectSettingsSchema = { - $id: '#/components/schemas/projectSettingsSchema', - type: 'object', - additionalProperties: false, - required: ['defaultStickiness'], - properties: { - defaultStickiness: { - type: 'string', - example: 'userId', - nullable: true, - }, - mode: { - type: 'string', - enum: ['open', 'protected', 'private'], - nullable: true, - }, - }, - components: {}, -} as const; - -export type ProjectSettingsSchema = FromSchema; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 53abc494b6..272b39ccd5 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -7,7 +7,6 @@ import { IUnleashServices, NONE, serializeDates, - UPDATE_PROJECT, } from '../../../types'; import ProjectFeaturesController from './project-features'; import EnvironmentsController from './environments'; @@ -16,11 +15,8 @@ import ProjectService from '../../../services/project-service'; import VariantsController from './variants'; import { createResponseSchema, - emptyResponse, ProjectOverviewSchema, projectOverviewSchema, - ProjectSettingsSchema, - projectSettingsSchema, projectsSchema, ProjectsSchema, } from '../../../openapi'; @@ -28,10 +24,6 @@ import { OpenApiService, SettingService } from '../../../services'; import { IAuthRequest } from '../../unleash-types'; import { ProjectApiTokenController } from './api-token'; import ProjectArchiveController from './project-archive'; -import NotFoundError from '../../../error/notfound-error'; - -const STICKINESS_KEY = 'stickiness'; -const DEFAULT_STICKINESS = 'default'; export default class ProjectApi extends Controller { private projectService: ProjectService; @@ -78,40 +70,6 @@ export default class ProjectApi extends Controller { ], }); - this.route({ - method: 'get', - path: '/:projectId/settings', - handler: this.getProjectSettings, - permission: NONE, - middleware: [ - services.openApiService.validPath({ - tags: ['Projects'], - operationId: 'getProjectSettings', - responses: { - 200: createResponseSchema('projectSettingsSchema'), - 404: emptyResponse, - }, - }), - ], - }); - - this.route({ - method: 'post', - path: '/:projectId/settings', - handler: this.setProjectSettings, - permission: UPDATE_PROJECT, - middleware: [ - services.openApiService.validPath({ - tags: ['Projects'], - operationId: 'setProjectSettings', - responses: { - 200: createResponseSchema('projectSettingsSchema'), - 404: emptyResponse, - }, - }), - ], - }); - this.use('/', new ProjectFeaturesController(config, services).router); this.use('/', new EnvironmentsController(config, services).router); this.use('/', new ProjectHealthReport(config, services).router); @@ -159,61 +117,4 @@ export default class ProjectApi extends Controller { serializeDates(overview), ); } - - async getProjectSettings( - req: IAuthRequest, - res: Response, - ): Promise { - if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { - throw new NotFoundError('Project scoped stickiness is not enabled'); - } - const { projectId } = req.params; - const stickinessSettings = await this.settingService.get( - STICKINESS_KEY, - { - [projectId]: 'default', - }, - ); - this.openApiService.respondWithValidation( - 200, - res, - projectSettingsSchema.$id, - { defaultStickiness: stickinessSettings[projectId] }, - ); - } - - async setProjectSettings( - req: IAuthRequest< - IProjectParam, - ProjectSettingsSchema, - ProjectSettingsSchema, - unknown - >, - res: Response, - ): Promise { - if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { - throw new NotFoundError('Project scoped stickiness is not enabled'); - } - const { projectId } = req.params; - const { defaultStickiness } = req.body; - const stickinessSettings = await this.settingService.get<{}>( - STICKINESS_KEY, - { - [projectId]: DEFAULT_STICKINESS, - }, - ); - stickinessSettings[projectId] = defaultStickiness; - await this.settingService.insert( - STICKINESS_KEY, - stickinessSettings, - req.user.name, - ); - - this.openApiService.respondWithValidation( - 200, - res, - projectSettingsSchema.$id, - { defaultStickiness }, - ); - } } diff --git a/src/lib/services/project-schema.ts b/src/lib/services/project-schema.ts index 01e779328c..6051f55cee 100644 --- a/src/lib/services/project-schema.ts +++ b/src/lib/services/project-schema.ts @@ -8,5 +8,9 @@ export const projectSchema = joi name: joi.string().required(), description: joi.string().allow(null).allow('').optional(), mode: joi.string().valid('open', 'protected').default('open'), + defaultStickiness: joi + .string() + .valid('default', 'userId', 'sessionId', 'random') + .default('default'), }) .options({ allowUnknown: false, stripUnknown: true }); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 047b30e1a4..22650dc5ae 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -7,44 +7,50 @@ import { nameType } from '../routes/util'; import { projectSchema } from './project-schema'; import NotFoundError from '../error/notfound-error'; import { + DEFAULT_PROJECT, + DefaultStickiness, FEATURE_ENVIRONMENT_ENABLED, + FeatureToggle, + IAccountStore, + IEnvironmentStore, + IEventStore, + IFeatureEnvironmentStore, + IFeatureToggleStore, + IFeatureTypeStore, + IProject, + IProjectOverview, + IProjectWithCount, + IUnleashConfig, + IUnleashStores, + IUserWithRole, + MOVE_FEATURE_TOGGLE, PROJECT_CREATED, PROJECT_DELETED, PROJECT_UPDATED, ProjectGroupAddedEvent, ProjectGroupRemovedEvent, ProjectGroupUpdateRoleEvent, + ProjectMode, ProjectUserAddedEvent, ProjectUserRemovedEvent, ProjectUserUpdateRoleEvent, -} from '../types/events'; -import { IAccountStore, IUnleashConfig, IUnleashStores } from '../types'; -import { - FeatureToggle, - IProject, - IProjectOverview, - IProjectWithCount, - IUserWithRole, RoleName, -} from '../types/model'; -import { IEnvironmentStore } from '../types/stores/environment-store'; -import { IFeatureTypeStore } from '../types/stores/feature-type-store'; -import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; -import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; -import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; +} from '../types'; +import { + IProjectQuery, + IProjectSettings, + IProjectStore, +} from '../types/stores/project-store'; import { IProjectAccessModel, IRoleDescriptor, } from '../types/stores/access-store'; -import { IEventStore } from '../types/stores/event-store'; import FeatureToggleService from './feature-toggle-service'; -import { MOVE_FEATURE_TOGGLE } from '../types/permissions'; import NoAccessError from '../error/no-access-error'; import IncompatibleProjectError from '../error/incompatible-project-error'; -import { DEFAULT_PROJECT } from '../types/project'; import { IFeatureTagStore } from 'lib/types/stores/feature-tag-store'; import ProjectWithoutOwnerError from '../error/project-without-owner-error'; -import { arraysHaveSameItems } from '../util/arraysHaveSameItems'; +import { arraysHaveSameItems } from '../util'; import { GroupService } from './group-service'; import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group'; import { FavoritesService } from './favorites-service'; @@ -165,7 +171,10 @@ export default class ProjectService { } async createProject( - newProject: Pick, + newProject: Pick< + IProject, + 'id' | 'name' | 'mode' | 'defaultStickiness' + >, user: IUser, ): Promise { const data = await projectSchema.validateAsync(newProject); @@ -827,6 +836,7 @@ export default class ProjectService { name: project.name, description: project.description, mode: project.mode, + defaultStickiness: project.defaultStickiness || 'default', health: project.health || 0, favorite: favorite, updatedAt: project.updatedAt, @@ -836,4 +846,20 @@ export default class ProjectService { version: 1, }; } + + async getProjectSettings(projectId: string): Promise { + return this.store.getProjectSettings(projectId); + } + + async setProjectSettings( + projectId: string, + defaultStickiness: DefaultStickiness, + mode: ProjectMode, + ): Promise { + return this.store.setProjectSettings( + projectId, + defaultStickiness, + mode, + ); + } } diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 0fca231e12..3928317733 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -175,6 +175,8 @@ export interface IFeatureOverview { export type ProjectMode = 'open' | 'protected'; +export type DefaultStickiness = 'default' | 'sessionId' | 'userId' | 'random'; + export interface IProjectOverview { name: string; description: string; @@ -187,6 +189,8 @@ export interface IProjectOverview { updatedAt?: Date; stats?: IProjectStats; mode: ProjectMode; + + defaultStickiness: DefaultStickiness; } export interface IProjectHealthReport extends IProjectOverview { @@ -370,6 +374,7 @@ export interface IProject { updatedAt?: Date; changeRequestsEnabled?: boolean; mode: ProjectMode; + defaultStickiness?: DefaultStickiness; } export interface ICustomRole { diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index 958b05d97b..921c9880de 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -3,6 +3,7 @@ import { IProjectMembersCount, } from '../../db/project-store'; import { + DefaultStickiness, IEnvironment, IProject, IProjectWithCount, @@ -19,6 +20,16 @@ export interface IProjectInsert { mode: ProjectMode; } +export interface IProjectSettings { + mode: ProjectMode; + defaultStickiness: DefaultStickiness; +} + +export interface IProjectSettingsRow { + project_mode: ProjectMode; + default_stickiness: DefaultStickiness; +} + export interface IProjectArchived { id: string; archived: boolean; @@ -86,4 +97,11 @@ export interface IProjectStore extends Store { environment: string, projects: string[], ): Promise; + + getProjectSettings(projectId: string): Promise; + setProjectSettings( + projectId: string, + defaultStickiness: DefaultStickiness, + mode: ProjectMode, + ): Promise; } diff --git a/src/test/e2e/api/admin/project/projects.e2e.test.ts b/src/test/e2e/api/admin/project/projects.e2e.test.ts index f41353ffb4..c83093d480 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -31,6 +31,7 @@ test('Should ONLY return default project', async () => { name: 'test', description: '', mode: 'open', + defaultStickiness: 'default', }); const { body } = await app.request @@ -41,27 +42,3 @@ test('Should ONLY return default project', async () => { expect(body.projects).toHaveLength(1); expect(body.projects[0].id).toBe('default'); }); - -test('Should store and retrieve default project stickiness', async () => { - const appWithDefaultStickiness = await setupAppWithCustomConfig(db.stores, { - experimental: { - flags: { - projectScopedStickiness: true, - strictSchemaValidation: true, - }, - }, - }); - const reqBody = { defaultStickiness: 'userId' }; - - await appWithDefaultStickiness.request - .post('/api/admin/projects/default/settings') - .send(reqBody) - .expect(200); - - const { body } = await appWithDefaultStickiness.request - .get('/api/admin/projects/default/settings') - .expect(200) - .expect('Content-Type', /json/); - - expect(body).toStrictEqual(reqBody); -}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 36d6971e2e..a8b322054b 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1841,6 +1841,17 @@ exports[`should serve the OpenAPI spec 1`] = ` "healthOverviewSchema": { "additionalProperties": false, "properties": { + "defaultStickiness": { + "description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy", + "enum": [ + "default", + "userId", + "sessionId", + "random", + ], + "example": "userId", + "type": "string", + }, "description": { "nullable": true, "type": "string", @@ -1904,6 +1915,17 @@ exports[`should serve the OpenAPI spec 1`] = ` "activeCount": { "type": "number", }, + "defaultStickiness": { + "description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy", + "enum": [ + "default", + "userId", + "sessionId", + "random", + ], + "example": "userId", + "type": "string", + }, "description": { "nullable": true, "type": "string", @@ -2759,6 +2781,17 @@ exports[`should serve the OpenAPI spec 1`] = ` "additionalProperties": false, "description": "A high-level overview of a project. It contains information such as project statistics, the name of the project, what members and what features it contains, etc.", "properties": { + "defaultStickiness": { + "description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy", + "enum": [ + "default", + "userId", + "sessionId", + "random", + ], + "example": "userId", + "type": "string", + }, "description": { "description": "Additional information about the project", "example": "DX squad feature release", @@ -2841,6 +2874,17 @@ exports[`should serve the OpenAPI spec 1`] = ` "format": "date-time", "type": "string", }, + "defaultStickiness": { + "description": "A default stickiness for the project affecting the default stickiness value for variants and Gradual Rollout strategy", + "enum": [ + "default", + "userId", + "sessionId", + "random", + ], + "example": "userId", + "type": "string", + }, "description": { "description": "Additional information about the project", "example": "DX squad feature release", @@ -2898,29 +2942,6 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, - "projectSettingsSchema": { - "additionalProperties": false, - "properties": { - "defaultStickiness": { - "example": "userId", - "nullable": true, - "type": "string", - }, - "mode": { - "enum": [ - "open", - "protected", - "private", - ], - "nullable": true, - "type": "string", - }, - }, - "required": [ - "defaultStickiness", - ], - "type": "object", - }, "projectStatsSchema": { "additionalProperties": false, "description": "Statistics for a project, including the average time to production, number of features created, the project activity and more. @@ -7561,70 +7582,6 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, - "/api/admin/projects/{projectId}/settings": { - "get": { - "operationId": "getProjectSettings", - "parameters": [ - { - "in": "path", - "name": "projectId", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/projectSettingsSchema", - }, - }, - }, - "description": "projectSettingsSchema", - }, - "404": { - "description": "This response has no body.", - }, - }, - "tags": [ - "Projects", - ], - }, - "post": { - "operationId": "setProjectSettings", - "parameters": [ - { - "in": "path", - "name": "projectId", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/projectSettingsSchema", - }, - }, - }, - "description": "projectSettingsSchema", - }, - "404": { - "description": "This response has no body.", - }, - }, - "tags": [ - "Projects", - ], - }, - }, "/api/admin/projects/{projectId}/stale": { "post": { "description": "This endpoint stales the specified features.", diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts index bf453e635d..06b2f18606 100644 --- a/src/test/fixtures/fake-project-store.ts +++ b/src/test/fixtures/fake-project-store.ts @@ -1,12 +1,15 @@ import { IProjectHealthUpdate, IProjectInsert, + IProjectSettings, IProjectStore, } from '../../lib/types/stores/project-store'; import { + DefaultStickiness, IEnvironment, IProject, IProjectWithCount, + ProjectMode, } from '../../lib/types/model'; import NotFoundError from '../../lib/error/notfound-error'; import { @@ -161,4 +164,20 @@ export default class FakeProjectStore implements IProjectStore { ): Promise { throw new Error('Method not implemented'); } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getProjectSettings(projectId: string): Promise { + throw new Error('Method not implemented.'); + } + + setProjectSettings( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + projectId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + defaultStickiness: DefaultStickiness, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mode: ProjectMode, + ): Promise { + throw new Error('Method not implemented.'); + } }