1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

fix: reject duplicate segment names (#855)

* fix: reject duplicate segment names

* fix: useSegmentValidation now takes into account initial value

* refactor: add segments e2e test

* refactor: add github action from segments e2e test

* refactor: use enterprise edition for all e2e tests

* refactor: use enterprise edition for all e2e tests
This commit is contained in:
Nuno Góis 2022-04-08 11:34:59 +01:00 committed by GitHub
parent b23226370a
commit 1132a79f6d
13 changed files with 193 additions and 7 deletions

View File

@ -17,7 +17,7 @@ jobs:
- name: Run Cypress
uses: cypress-io/github-action@v2
with:
env: AUTH_USER=test@unleash-e2e.com
env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
config: baseUrl=${{ github.event.deployment_status.target_url }}
record: true
spec: cypress/integration/feature/feature.spec.ts

View File

@ -0,0 +1,25 @@
name: e2e:segments
# https://docs.github.com/en/actions/reference/events-that-trigger-workflows
on: [deployment_status]
jobs:
e2e:
# only runs this job on successful deploy
if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: |
echo "$GITHUB_CONTEXT"
- name: Checkout
uses: actions/checkout@v3
- name: Run Cypress
uses: cypress-io/github-action@v2
with:
env: AUTH_USER=admin,AUTH_PASSWORD=unleash4all
config: baseUrl=${{ github.event.deployment_status.target_url }}
record: true
spec: cypress/integration/segments/segments.spec.ts
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View File

@ -0,0 +1,77 @@
/// <reference types="cypress" />
export {};
const AUTH_USER = Cypress.env('AUTH_USER');
const AUTH_PASSWORD = Cypress.env('AUTH_PASSWORD');
const randomId = String(Math.random()).split('.')[1];
const segmentName = `unleash-e2e-${randomId}`;
Cypress.config({
experimentalSessionSupport: true,
});
// Disable all active splash pages by visiting them.
const disableActiveSplashScreens = () => {
cy.visit(`/splash/operators`);
};
describe('segments', () => {
before(() => {
disableActiveSplashScreens();
});
beforeEach(() => {
cy.session(AUTH_USER, () => {
cy.visit('/');
cy.wait(1000);
cy.get("[data-test='LOGIN_EMAIL_ID']").type(AUTH_USER);
if (AUTH_PASSWORD) {
cy.get("[data-test='LOGIN_PASSWORD_ID']").type(AUTH_PASSWORD);
}
cy.get("[data-test='LOGIN_BUTTON']").click();
// Wait for the login redirect to complete.
cy.get("[data-test='HEADER_USER_AVATAR']");
});
cy.visit('/segments');
});
it('can create a segment', () => {
if (document.querySelector("[data-test='CLOSE_SPLASH']")) {
cy.get("[data-test='CLOSE_SPLASH']").click();
}
cy.get("[data-test='NAVIGATE_TO_CREATE_SEGMENT']").click();
cy.intercept('POST', '/api/admin/segments').as('createSegment');
cy.get("[data-test='SEGMENT_NAME_ID']").type(segmentName);
cy.get("[data-test='SEGMENT_DESC_ID']").type('hello-world');
cy.get("[data-test='SEGMENT_NEXT_BTN_ID']").click();
cy.get("[data-test='SEGMENT_CREATE_BTN_ID']").click();
cy.wait('@createSegment');
cy.contains(segmentName);
});
it('gives an error if a segment exists with the same name', () => {
cy.get("[data-test='NAVIGATE_TO_CREATE_SEGMENT']").click();
cy.get("[data-test='SEGMENT_NAME_ID']").type(segmentName);
cy.get("[data-test='SEGMENT_NEXT_BTN_ID']").should('be.disabled');
cy.get("[data-test='INPUT_ERROR_TEXT']").contains(
'Segment name already exists'
);
});
it('can delete a segment', () => {
cy.get(`[data-test='SEGMENT_DELETE_BTN_ID_${segmentName}']`).click();
cy.get("[data-test='SEGMENT_DIALOG_NAME_ID']").type(segmentName);
cy.get("[data-test='DIALOGUE_CONFIRM_ID'").click();
cy.contains(segmentName).should('not.exist');
});
});

View File

@ -15,6 +15,7 @@ import { feedbackCESContext } from 'component/feedback/FeedbackCESContext/Feedba
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
export const CreateSegment = () => {
const { uiConfig } = useUiConfig();
@ -96,6 +97,7 @@ export const CreateSegment = () => {
name="segment"
permission={CREATE_SEGMENT}
disabled={!hasValidConstraints || atSegmentValuesLimit}
data-test={SEGMENT_CREATE_BTN_ID}
/>
</SegmentForm>
</FormTemplate>

View File

@ -17,6 +17,7 @@ import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { segmentsDocsLink } from 'component/segments/SegmentDocs/SegmentDocs';
import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValuesCount';
import { SEGMENT_VALUES_LIMIT } from 'utils/segmentLimits';
import { SEGMENT_SAVE_BTN_ID } from 'utils/testIds';
export const EditSegment = () => {
const segmentId = useRequiredPathParam('segmentId');
@ -98,6 +99,7 @@ export const EditSegment = () => {
<UpdateButton
permission={UPDATE_SEGMENT}
disabled={!hasValidConstraints || atSegmentValuesLimit}
data-test={SEGMENT_SAVE_BTN_ID}
/>
</SegmentForm>
</FormTemplate>

View File

@ -3,6 +3,7 @@ import Dialogue from 'component/common/Dialogue';
import Input from 'component/common/Input/Input';
import { useStyles } from './SegmentDeleteConfirm.styles';
import { ISegment } from 'interfaces/segment';
import { SEGMENT_DIALOG_NAME_ID } from 'utils/testIds';
interface ISegmentDeleteConfirmProps {
segment: ISegment;
@ -54,6 +55,7 @@ export const SegmentDeleteConfirm = ({
value={confirmName}
label="Segment name"
className={styles.deleteInput}
data-test={SEGMENT_DIALOG_NAME_ID}
/>
</form>
</Dialogue>

View File

@ -4,6 +4,11 @@ import React from 'react';
import { useHistory } from 'react-router-dom';
import { useStyles } from 'component/segments/SegmentFormStepOne/SegmentFormStepOne.styles';
import { SegmentFormStep } from '../SegmentForm/SegmentForm';
import {
SEGMENT_NAME_ID,
SEGMENT_DESC_ID,
SEGMENT_NEXT_BTN_ID,
} from 'utils/testIds';
interface ISegmentFormPartOneProps {
name: string;
@ -41,9 +46,9 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
onChange={e => setName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
autoFocus
required
data-test={SEGMENT_NAME_ID}
/>
<p className={styles.inputDescription}>
What is the segment description?
@ -55,7 +60,7 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
onChange={e => setDescription(e.target.value)}
error={Boolean(errors.description)}
errorText={errors.description}
onFocus={() => clearErrors()}
data-test={SEGMENT_DESC_ID}
/>
</div>
<div className={styles.buttonContainer}>
@ -64,7 +69,8 @@ export const SegmentFormStepOne: React.FC<ISegmentFormPartOneProps> = ({
variant="contained"
color="primary"
onClick={() => setCurrentStep(2)}
disabled={name.length === 0}
disabled={name.length === 0 || Boolean(errors.name)}
data-test={SEGMENT_NEXT_BTN_ID}
>
Next
</Button>

View File

@ -28,6 +28,7 @@ import PageContent from 'component/common/PageContent';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { SegmentDelete } from '../SegmentDelete/SegmentDelete';
import { SegmentDocsWarning } from 'component/segments/SegmentDocs/SegmentDocs';
import { NAVIGATE_TO_CREATE_SEGMENT } from 'utils/testIds';
export const SegmentsList = () => {
const history = useHistory();
@ -101,6 +102,7 @@ export const SegmentsList = () => {
<PermissionButton
onClick={() => history.push('/segments/create')}
permission={CREATE_SEGMENT}
data-test={NAVIGATE_TO_CREATE_SEGMENT}
>
New Segment
</PermissionButton>

View File

@ -9,6 +9,7 @@ import PermissionIconButton from 'component/common/PermissionIconButton/Permissi
import TimeAgo from 'react-timeago';
import { ISegment } from 'interfaces/segment';
import { useHistory } from 'react-router-dom';
import { SEGMENT_DELETE_BTN_ID } from 'utils/testIds';
interface ISegmentListItemProps {
id: number;
@ -82,6 +83,7 @@ export const SegmentListItem = ({
setDelDialog(true);
}}
permission={ADMIN}
data-test={`${SEGMENT_DELETE_BTN_ID}_${name}`}
>
<Delete />
</PermissionIconButton>

View File

@ -1,5 +1,6 @@
import { IConstraint } from 'interfaces/strategy';
import { useEffect, useState } from 'react';
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
export const useSegmentForm = (
initialName = '',
@ -11,6 +12,7 @@ export const useSegmentForm = (
const [constraints, setConstraints] =
useState<IConstraint[]>(initialConstraints);
const [errors, setErrors] = useState({});
const nameError = useSegmentValidation(name, initialName);
useEffect(() => {
setName(initialName);
@ -25,6 +27,13 @@ export const useSegmentForm = (
// eslint-disable-next-line
}, [JSON.stringify(initialConstraints)]);
useEffect(() => {
setErrors(errors => ({
...errors,
name: nameError,
}));
}, [nameError]);
const getSegmentPayload = () => {
return {
name,

View File

@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { formatApiPath } from 'utils/formatPath';
export const useSegmentValidation = (
name: string,
initialName: string
): string | undefined => {
const [error, setError] = useState<string>();
const nameHasChanged = name !== initialName;
useEffect(() => {
setError(undefined);
if (name && nameHasChanged) {
fetchNewNameValidation(name)
.then(parseValidationResponse)
.then(setError)
.catch(() => setError(undefined));
}
}, [name, nameHasChanged]);
return error;
};
const fetchNewNameValidation = (name: string): Promise<Response> =>
fetch(formatApiPath('api/admin/segments/validate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const parseValidationResponse = async (
res: Response
): Promise<string | undefined> => {
if (res.ok) {
return;
}
const json = await res.json();
return json.details[0].message;
};

View File

@ -1,5 +1,6 @@
/* NAVIGATION */
export const NAVIGATE_TO_CREATE_FEATURE = 'NAVIGATE_TO_CREATE_FEATURE';
export const NAVIGATE_TO_CREATE_SEGMENT = 'NAVIGATE_TO_CREATE_SEGMENT';
export const CREATE_API_TOKEN_BUTTON = 'CREATE_API_TOKEN_BUTTON';
/* CREATE FEATURE */
@ -8,6 +9,15 @@ export const CF_TYPE_ID = 'CF_TYPE_ID';
export const CF_DESC_ID = 'CF_DESC_ID';
export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
/* SEGMENT */
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';
export const SEGMENT_NEXT_BTN_ID = 'SEGMENT_NEXT_BTN_ID';
export const SEGMENT_CREATE_BTN_ID = 'SEGMENT_CREATE_BTN_ID';
export const SEGMENT_SAVE_BTN_ID = 'SEGMENT_SAVE_BTN_ID';
export const SEGMENT_DELETE_BTN_ID = 'SEGMENT_DELETE_BTN_ID';
export const SEGMENT_DIALOG_NAME_ID = 'SEGMENT_DIALOG_NAME_ID';
/* LOGIN */
export const LOGIN_EMAIL_ID = 'LOGIN_EMAIL_ID';
export const LOGIN_BUTTON = 'LOGIN_BUTTON';

View File

@ -1,7 +1,16 @@
{
"rewrites": [
{ "source": "/api/:match*", "destination": "https://unleash.herokuapp.com/api/:match*" },
{ "source": "/logout", "destination": "https://unleash.herokuapp.com/logout" },
{ "source": "/auth/:match*", "destination": "https://unleash.herokuapp.com/auth/:match*" }
{
"source": "/api/:match*",
"destination": "https://unleash4.herokuapp.com/api/:match*"
},
{
"source": "/logout",
"destination": "https://unleash4.herokuapp.com/logout"
},
{
"source": "/auth/:match*",
"destination": "https://unleash4.herokuapp.com/auth/:match*"
}
]
}