diff options
Diffstat (limited to 'spec/frontend/feature_flags')
26 files changed, 5033 insertions, 0 deletions
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js new file mode 100644 index 00000000000..0e364c47f8d --- /dev/null +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -0,0 +1,159 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import Component from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import Callout from '~/vue_shared/components/callout.vue'; + +describe('Configure Feature Flags Modal', () => { + const mockEvent = { preventDefault: jest.fn() }; + const provide = { + projectName: 'fakeProjectName', + featureFlagsHelpPagePath: '/help/path', + featureFlagsClientLibrariesHelpPagePath: '/help/path/#flags', + featureFlagsClientExampleHelpPagePath: '/feature-flags#clientexample', + unleashApiUrl: '/api/url', + }; + + const propsData = { + instanceId: 'instance-id-token', + isRotating: false, + hasRotateError: false, + canUserRotateToken: true, + }; + + let wrapper; + const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(Component, { + provide, + stubs: { GlSprintf }, + propsData: { + ...propsData, + ...props, + }, + ...options, + }); + }; + + const findGlModal = () => wrapper.find(GlModal); + const findPrimaryAction = () => findGlModal().props('actionPrimary'); + const findProjectNameInput = () => wrapper.find('#project_name_verification'); + const findDangerCallout = () => + wrapper.findAll(Callout).filter(c => c.props('category') === 'danger'); + + describe('idle', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should have Primary and Cancel actions', () => { + expect(findGlModal().props('actionCancel').text).toBe('Close'); + expect(findPrimaryAction().text).toBe('Regenerate instance ID'); + }); + + it('should default disable the primary action', async () => { + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(true); + }); + + it('should emit a `token` event when clicking on the Primary action', async () => { + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('token')).toEqual([[]]); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should clear the project name input after generating the token', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should provide an input for filling the project name', () => { + expect(findProjectNameInput().exists()).toBe(true); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should display an help text', () => { + const help = wrapper.find('p'); + expect(help.text()).toMatch(/More Information/); + }); + + it('should have links to the documentation', () => { + expect(wrapper.find('[data-testid="help-link"]').attributes('href')).toBe( + provide.featureFlagsHelpPagePath, + ); + expect(wrapper.find('[data-testid="help-client-link"]').attributes('href')).toBe( + provide.featureFlagsClientLibrariesHelpPagePath, + ); + }); + + it('should display one and only one danger callout', () => { + const dangerCallout = findDangerCallout(); + expect(dangerCallout.length).toBe(1); + expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/); + }); + + it('should display a message asking to fill the project name', () => { + expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch( + provide.projectName, + ); + }); + + it('should display the api URL in an input box', () => { + const input = wrapper.find('#api_url'); + expect(input.element.value).toBe('/api/url'); + }); + + it('should display the instance ID in an input box', () => { + const input = wrapper.find('#instance_id'); + expect(input.element.value).toBe('instance-id-token'); + }); + }); + + describe('verified', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should enable the primary action', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + await wrapper.vm.$nextTick(); + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(false); + }); + }); + + describe('cannot rotate token', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { canUserRotateToken: false })); + + it('should not display the primary action', async () => { + expect(findPrimaryAction()).toBe(null); + }); + + it('shold not display regenerating instance ID', async () => { + expect(findDangerCallout().exists()).toBe(false); + }); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().exists()).toBe(false); + }); + }); + + describe('has rotate error', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { hasRotateError: false })); + + it('should display an error', async () => { + expect(wrapper.find('.text-danger')).toExist(); + expect(wrapper.find('[name="warning"]')).toExist(); + }); + }); + + describe('is rotating', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { isRotating: true })); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().attributes('disabled')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js new file mode 100644 index 00000000000..6a394251060 --- /dev/null +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -0,0 +1,183 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlToggle, GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants'; +import Form from '~/feature_flags/components/form.vue'; +import createStore from '~/feature_flags/store/edit'; +import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; +import axios from '~/lib/utils/axios_utils'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const userCalloutId = 'feature_flags_new_version'; +const userCalloutsPath = `${TEST_HOST}/user_callouts`; + +describe('Edit feature flag form', () => { + let wrapper; + let mock; + + const store = createStore({ + path: '/feature_flags', + endpoint: `${TEST_HOST}/feature_flags.json`, + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(EditFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + glFeatures: { + featureFlagsNewVersion: true, + }, + ...opts, + }, + }); + }; + + beforeEach(done => { + mock = new MockAdapter(axios); + mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { + id: 21, + iid: 5, + active: true, + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + name: 'feature_flag', + description: '', + version: LEGACY_FLAG, + edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', + destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', + scopes: [ + { + id: 21, + active: false, + environment_scope: '*', + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + }, + ], + }); + factory(); + setImmediate(() => done()); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + it('should display the iid', () => { + expect(wrapper.find('h3').text()).toContain('^5'); + }); + + it('should render the toggle', () => { + expect(wrapper.find(GlToggle).exists()).toBe(true); + }); + + it('should set the value of the toggle to whether or not the flag is active', () => { + expect(wrapper.find(GlToggle).props('value')).toBe(true); + }); + + it('should not alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags'); + }); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('receiveUpdateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert-danger').exists()).toEqual(true); + expect(wrapper.find('.alert-danger').text()).toContain('The name is required'); + }); + }); + }); + + describe('without error', () => { + it('renders form title', () => { + expect(wrapper.text()).toContain('^5 feature_flag'); + }); + + it('should render feature flag form', () => { + expect(wrapper.find(Form).exists()).toEqual(true); + }); + + it('should set the version of the form from the feature flag', () => { + expect(wrapper.find(Form).props('version')).toBe(LEGACY_FLAG); + + mock.resetHandlers(); + + mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { + id: 21, + iid: 5, + active: true, + created_at: '2019-01-17T17:27:39.778Z', + updated_at: '2019-01-17T17:27:39.778Z', + name: 'feature_flag', + description: '', + version: NEW_VERSION_FLAG, + edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', + destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', + strategies: [], + }); + + factory(); + + return axios.waitForAll().then(() => { + expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG); + }); + }); + + it('should track when the toggle is clicked', () => { + const toggle = wrapper.find(GlToggle); + const spy = mockTracking('_category_', toggle.element, jest.spyOn); + + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + beforeEach(() => { + factory({ glFeatures: { featureFlagsNewVersion: false } }); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js new file mode 100644 index 00000000000..917f5f5ccd3 --- /dev/null +++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js @@ -0,0 +1,147 @@ +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +describe('Feature flags > Environments dropdown ', () => { + let wrapper; + let mock; + const results = ['production', 'staging']; + const factory = props => { + wrapper = shallowMount(EnvironmentsDropdown, { + propsData: { + ...props, + }, + provide: { + environmentsEndpoint: `${TEST_HOST}/environments.json'`, + }, + }); + }; + + const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType); + const findDropdownMenu = () => wrapper.find('.dropdown-menu'); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + describe('without value', () => { + it('renders the placeholder', () => { + factory(); + expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec'); + }); + }); + + describe('with value', () => { + it('sets filter to equal the value', () => { + factory({ value: 'production' }); + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + }); + + describe('on focus', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on keyup', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('keyup'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on input change', () => { + describe('on success', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('sets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + + describe('with received data', () => { + it('sets is loading to false', () => { + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('shows the suggestions', () => { + expect(findDropdownMenu().exists()).toBe(true); + }); + + it('emits event when a suggestion is clicked', async () => { + const button = wrapper + .findAll(GlDeprecatedButton) + .filter(b => b.text() === 'production') + .at(0); + button.vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]); + }); + }); + + describe('on click clear button', () => { + beforeEach(async () => { + wrapper.find(GlDeprecatedButton).vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('resets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe(''); + }); + + it('closes list of suggestions', () => { + expect(wrapper.vm.showSuggestions).toBe(false); + }); + }); + }); + }); + + describe('on click create button', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('emits create event', async () => { + wrapper + .findAll(GlDeprecatedButton) + .at(0) + .vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('createClicked')).toEqual([['production']]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js new file mode 100644 index 00000000000..3c1234fea94 --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -0,0 +1,371 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import createStore from '~/feature_flags/store/index'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; +import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import axios from '~/lib/utils/axios_utils'; +import { getRequestData, userList } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Feature flags', () => { + const mockData = { + canUserConfigure: true, + csrfToken: 'testToken', + featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', + featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', + featureFlagsHelpPagePath: '/help/feature-flags', + featureFlagsLimit: '200', + featureFlagsLimitExceeded: false, + newFeatureFlagPath: 'feature-flags/new', + newUserListPath: '/user-list/new', + unleashApiUrl: `${TEST_HOST}/api/unleash`, + projectName: 'fakeProjectName', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + }; + + const mockState = { + endpoint: `${TEST_HOST}/endpoint.json`, + projectId: '8', + unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', + }; + + let wrapper; + let mock; + let store; + + const factory = (provide = mockData, fn = shallowMount) => { + store = createStore(mockState); + wrapper = fn(FeatureFlagsComponent, { + localVue, + store, + provide, + stubs: { + FeatureFlagsTab, + }, + }); + }; + + const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]'); + const newButton = () => wrapper.find('[data-testid="ff-new-button"]'); + const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]'); + const limitAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({ + data: [userList], + headers: { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '8', + 'X-Prev-Page': '', + 'X-TOTAL': '40', + 'X-Total-Pages': '5', + }, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + describe('when limit exceeded', () => { + const provideData = { ...mockData, featureFlagsLimitExceeded: true }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + setImmediate(done); + }); + + it('makes the new feature flag button do nothing if clicked', () => { + expect(newButton().exists()).toBe(true); + expect(newButton().props('disabled')).toBe(false); + expect(newButton().props('href')).toBe(undefined); + }); + + it('shows a feature flags limit reached alert', () => { + expect(limitAlert().exists()).toBe(true); + expect( + limitAlert() + .find(GlSprintf) + .attributes('message'), + ).toContain('Feature flags limit reached'); + }); + + describe('when the alert is dismissed', () => { + beforeEach(async () => { + await limitAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', async () => { + expect(limitAlert().exists()).toBe(false); + }); + + it('re-shows the alert if the new feature flag button is clicked', async () => { + await newButton().vm.$emit('click'); + + expect(limitAlert().exists()).toBe(true); + }); + }); + }); + + describe('without permissions', () => { + const provideData = { + ...mockData, + canUserConfigure: false, + canUserRotateToken: false, + newFeatureFlagPath: null, + newUserListPath: null, + }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + setImmediate(done); + }); + + it('does not render configure button', () => { + expect(configureButton().exists()).toBe(false); + }); + + it('does not render new feature flag button', () => { + expect(newButton().exists()).toBe(false); + }); + + it('does not render new user list button', () => { + expect(newUserListButton().exists()).toBe(false); + }); + }); + + describe('loading state', () => { + it('renders a loading icon', () => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, {}); + + factory(); + + const loadingElement = wrapper.find(GlLoadingIcon); + + expect(loadingElement.exists()).toBe(true); + expect(loadingElement.props('label')).toEqual('Loading feature flags'); + }); + }); + + describe('successful request', () => { + describe('without feature flags', () => { + let emptyState; + + beforeEach(async () => { + mock.onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply( + 200, + { + feature_flags: [], + count: { + all: 0, + enabled: 0, + disabled: 0, + }, + }, + {}, + ); + + factory(); + await wrapper.vm.$nextTick(); + + emptyState = wrapper.find(GlEmptyState); + }); + + it('should render the empty state', async () => { + expect(emptyState.exists()).toBe(true); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('in feature flags tab', () => { + it('renders generic title', () => { + expect(emptyState.props('title')).toEqual('Get started with feature flags'); + }); + }); + }); + + describe('with paginated feature flags', () => { + beforeEach(done => { + mock + .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }); + + factory(); + jest.spyOn(store, 'dispatch'); + setImmediate(done); + }); + + it('should render a table with feature flags', () => { + const table = wrapper.find(FeatureFlagsTable); + expect(table.exists()).toBe(true); + expect(table.props(FEATURE_FLAG_SCOPE)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: getRequestData.feature_flags[0].name, + description: getRequestData.feature_flags[0].description, + }), + ]), + ); + }); + + it('should toggle a flag when receiving the toggle-flag event', () => { + const table = wrapper.find(FeatureFlagsTable); + + const [flag] = table.props(FEATURE_FLAG_SCOPE); + table.vm.$emit('toggle-flag', flag); + + expect(store.dispatch).toHaveBeenCalledWith('toggleFeatureFlag', flag); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('pagination', () => { + it('should render pagination', () => { + expect(wrapper.find(TablePagination).exists()).toBe(true); + }); + + it('should make an API request when page is clicked', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find(TablePagination).vm.change(4); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: FEATURE_FLAG_SCOPE, + page: '4', + }); + }); + + it('should make an API request when using tabs', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: USER_LIST_SCOPE, + page: '1', + }); + }); + }); + }); + + describe('in user lists tab', () => { + beforeEach(done => { + factory(); + setImmediate(done); + }); + beforeEach(() => { + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + return wrapper.vm.$nextTick(); + }); + + it('should display the user list table', () => { + expect(wrapper.find(UserListsTable).exists()).toBe(true); + }); + + it('should set the user lists to display', () => { + expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]); + }); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(done => { + mock + .onGet(mockState.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(500, {}); + Api.fetchFeatureFlagUserLists.mockRejectedValueOnce(); + + factory(); + setImmediate(done); + }); + + it('should render error state', () => { + const emptyState = wrapper.find(GlEmptyState); + expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.'); + expect(emptyState.props('description')).toEqual( + 'Try again in a few moments or contact your support team.', + ); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + }); + + describe('rotate instance id', () => { + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(); + setImmediate(done); + }); + + it('should fire the rotate action when a `token` event is received', () => { + const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId'); + const modal = wrapper.find(ConfigureFeatureFlagsModal); + modal.vm.$emit('token'); + + expect(actionSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js new file mode 100644 index 00000000000..bc90c5ceb2d --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js @@ -0,0 +1,168 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; + +const DEFAULT_PROPS = { + title: 'test', + count: 5, + alerts: ['an alert', 'another alert'], + isLoading: false, + loadingLabel: 'test loading', + errorState: false, + errorTitle: 'test title', + emptyState: true, + emptyTitle: 'test empty', +}; + +const DEFAULT_PROVIDE = { + errorStateSvgPath: '/error.svg', + featureFlagsHelpPagePath: '/help/page/path', +}; + +describe('feature_flags/components/feature_flags_tab.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount( + { + components: { + GlTabs, + FeatureFlagsTab, + }, + render(h) { + return h(GlTabs, [ + h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default), + ]); + }, + }, + { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: DEFAULT_PROVIDE, + slots: { + default: '<p data-testid="test-slot">testing</p>', + }, + }, + ); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('alerts', () => { + let alerts; + + beforeEach(() => { + wrapper = factory(); + alerts = wrapper.findAll(GlAlert); + }); + + it('should show any alerts', () => { + expect(alerts).toHaveLength(DEFAULT_PROPS.alerts.length); + alerts.wrappers.forEach((alert, i) => expect(alert.text()).toBe(DEFAULT_PROPS.alerts[i])); + }); + + it('should emit a dismiss event for a dismissed alert', () => { + alerts.at(0).vm.$emit('dismiss'); + + expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]); + }); + }); + + describe('loading', () => { + beforeEach(() => { + wrapper = factory({ isLoading: true }); + }); + + it('should show a loading icon and nothing else', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findAll(GlEmptyState)).toHaveLength(0); + }); + }); + + describe('error', () => { + let emptyState; + + beforeEach(() => { + wrapper = factory({ errorState: true }); + emptyState = wrapper.find(GlEmptyState); + }); + + it('should show an error state if there has been an error', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.errorTitle); + expect(emptyState.text()).toContain( + 'Try again in a few moments or contact your support team.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + }); + }); + + describe('empty', () => { + let emptyState; + let emptyStateLink; + + beforeEach(() => { + wrapper = factory({ emptyState: true }); + emptyState = wrapper.find(GlEmptyState); + emptyStateLink = emptyState.find(GlLink); + }); + + it('should show an empty state if it is empty', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle); + expect(emptyState.text()).toContain( + 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath); + expect(emptyStateLink.text()).toBe('More information'); + }); + }); + + describe('slot', () => { + let slot; + + beforeEach(async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + slot = wrapper.find('[data-testid="test-slot"]'); + }); + + it('should display the passed slot', () => { + expect(slot.exists()).toBe(true); + expect(slot.text()).toBe('testing'); + }); + }); + + describe('count', () => { + it('should display a count if there is one', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString()); + }); + it('should display 0 if there is no count', async () => { + wrapper = factory({ count: undefined }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe('0'); + }); + }); + + describe('title', () => { + it('should show the title', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe( + DEFAULT_PROPS.title, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js new file mode 100644 index 00000000000..a488662470e --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -0,0 +1,266 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlToggle, GlBadge } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + NEW_VERSION_FLAG, + LEGACY_FLAG, + DEFAULT_PERCENT_ROLLOUT, +} from '~/feature_flags/constants'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; + +const getDefaultProps = () => ({ + featureFlags: [ + { + id: 1, + iid: 1, + active: true, + name: 'flag name', + description: 'flag description', + destroy_path: 'destroy/path', + edit_path: 'edit/path', + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + active: true, + environmentScope: 'scope', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + shouldBeDestroyed: false, + }, + ], + }, + ], +}); + +describe('Feature flag table', () => { + let wrapper; + let props; + + const createWrapper = (propsData, opts = {}) => { + wrapper = shallowMount(FeatureFlagsTable, { + propsData, + provide: { + csrfToken: 'fakeToken', + }, + ...opts, + }); + }; + + beforeEach(() => { + props = getDefaultProps(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with an active scope and a standard rollout strategy', () => { + beforeEach(() => { + createWrapper(props); + }); + + it('Should render a table', () => { + expect(wrapper.classes('table-holder')).toBe(true); + }); + + it('Should render rows', () => { + expect(wrapper.find('.gl-responsive-table-row').exists()).toBe(true); + }); + + it('should render an ID column', () => { + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toEqual('^1'); + }); + + it('Should render a status column', () => { + const badge = wrapper.find('[data-testid="feature-flag-status-badge"]'); + + expect(badge.exists()).toBe(true); + expect(trimText(badge.text())).toEqual('Active'); + }); + + it('Should render a feature flag column', () => { + expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true); + expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name'); + + expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual( + 'flag description', + ); + }); + + it('should render an environments specs column', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(envColumn).toBeDefined(); + expect(trimText(envColumn.text())).toBe('scope'); + }); + + it('should render an environments specs badge with active class', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); + }); + + it('should render an actions column', () => { + expect(wrapper.find('.table-action-buttons').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-delete-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').attributes('href')).toEqual('edit/path'); + }); + }); + + describe('when active and with an update toggle', () => { + let toggle; + let spy; + + beforeEach(() => { + props.featureFlags[0].update_path = props.featureFlags[0].destroy_path; + createWrapper(props); + toggle = wrapper.find(GlToggle); + spy = mockTracking('_category_', toggle.element, jest.spyOn); + }); + + it('should have a toggle', () => { + expect(toggle.exists()).toBe(true); + expect(toggle.props('value')).toBe(true); + }); + + it('should trigger a toggle event', () => { + toggle.vm.$emit('change'); + const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active }; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]); + }); + }); + + it('should track a click', () => { + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + describe('with an active scope and a percentage rollout strategy', () => { + beforeEach(() => { + props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + props.featureFlags[0].scopes[0].rolloutPercentage = '54'; + createWrapper(props); + }); + + it('should render an environments specs badge with percentage', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%'); + }); + }); + + describe('with an inactive scope', () => { + beforeEach(() => { + props.featureFlags[0].scopes[0].active = false; + createWrapper(props); + }); + + it('should render an environments specs badge with inactive class', () => { + const envColumn = wrapper.find('.js-feature-flag-environments'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); + }); + }); + + describe('with a new version flag', () => { + let badges; + + beforeEach(() => { + const newVersionProps = { + ...props, + featureFlags: [ + { + id: 1, + iid: 1, + active: true, + name: 'flag name', + description: 'flag description', + destroy_path: 'destroy/path', + edit_path: 'edit/path', + version: NEW_VERSION_FLAG, + scopes: [], + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: '*' }], + }, + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }], + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3,4' }, + scopes: [{ environment_scope: 'review/*' }], + }, + { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + parameters: {}, + user_list: { name: 'test list' }, + scopes: [{ environment_scope: '*' }], + }, + ], + }, + ], + }; + createWrapper(newVersionProps, { + provide: { csrfToken: 'fakeToken', glFeatures: { featureFlagsNewVersion: true } }, + }); + + badges = wrapper.findAll('[data-testid="strategy-badge"]'); + }); + + it('shows All Environments if the environment scope is *', () => { + expect(badges.at(0).text()).toContain('All Environments'); + }); + + it('shows the environment scope if another is set', () => { + expect(badges.at(1).text()).toContain('production'); + expect(badges.at(1).text()).toContain('staging'); + expect(badges.at(2).text()).toContain('review/*'); + }); + + it('shows All Users for the default strategy', () => { + expect(badges.at(0).text()).toContain('All Users'); + }); + + it('shows the percent for a percent rollout', () => { + expect(badges.at(1).text()).toContain('Percent of users - 50%'); + }); + + it('shows the number of users for users with ID', () => { + expect(badges.at(2).text()).toContain('User IDs - 4 users'); + }); + + it('shows the name of a user list for user list', () => { + expect(badges.at(3).text()).toContain('User List - test list'); + }); + }); + + it('renders a feature flag without an iid', () => { + delete props.featureFlags[0].iid; + createWrapper(props); + + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toBe(''); + }); +}); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js new file mode 100644 index 00000000000..33c7eeb54b7 --- /dev/null +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -0,0 +1,493 @@ +import { uniqueId } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui'; +import Api from '~/api'; +import Form from '~/feature_flags/components/form.vue'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; +import Strategy from '~/feature_flags/components/strategy.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + INTERNAL_ID_PREFIX, + DEFAULT_PERCENT_ROLLOUT, + LEGACY_FLAG, + NEW_VERSION_FLAG, +} from '~/feature_flags/constants'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import { featureFlag, userList, allUsersStrategy } from '../mock_data'; + +jest.mock('~/api.js'); + +describe('feature flag form', () => { + let wrapper; + const requiredProps = { + cancelPath: 'feature_flags', + submitText: 'Create', + }; + + const requiredInjections = { + environmentsEndpoint: '/environments.json', + projectId: '1', + glFeatures: { + featureFlagPermissions: true, + featureFlagsNewVersion: true, + }, + }; + + const factory = (props = {}, provide = {}) => { + wrapper = shallowMount(Form, { + propsData: { ...requiredProps, ...props }, + provide: { + ...requiredInjections, + ...provide, + }, + }); + }; + + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render provided submitText', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-submit').text()).toEqual(requiredProps.submitText); + }); + + it('should render provided cancelPath', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath); + }); + + it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => { + factory(requiredProps); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false); + }); + + it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => { + factory( + {}, + { + ...requiredInjections, + featureFlagIssuesEndpoint: '/some/endpoint', + }, + ); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true); + }); + + describe('without provided data', () => { + beforeEach(() => { + factory(requiredProps); + }); + + it('should render name input text', () => { + expect(wrapper.find('#feature-flag-name').exists()).toBe(true); + }); + + it('should render description textarea', () => { + expect(wrapper.find('#feature-flag-description').exists()).toBe(true); + }); + + describe('scopes', () => { + it('should render scopes table', () => { + expect(wrapper.find('.js-scopes-table').exists()).toBe(true); + }); + + it('should render scopes table with a new row ', () => { + expect(wrapper.find('.js-add-new-scope').exists()).toBe(true); + }); + + describe('status toggle', () => { + describe('without filled text input', () => { + it('should add a new scope with the text value empty and the status', () => { + wrapper.find(ToggleButton).vm.$emit('change', true); + + expect(wrapper.vm.formScopes).toHaveLength(1); + expect(wrapper.vm.formScopes[0].active).toEqual(true); + expect(wrapper.vm.formScopes[0].environmentScope).toEqual(''); + + expect(wrapper.vm.newScope).toEqual(''); + }); + }); + + it('should be disabled if the feature flag is not active', done => { + wrapper.setProps({ active: false }); + wrapper.vm.$nextTick(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true); + done(); + }); + }); + }); + }); + }); + + describe('with provided data', () => { + beforeEach(() => { + factory({ + ...requiredProps, + name: featureFlag.name, + description: featureFlag.description, + active: true, + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + active: true, + environmentScope: 'scope', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '54', + rolloutUserIds: '123', + shouldIncludeUserIds: true, + }, + { + id: 2, + active: true, + environmentScope: 'scope', + canUpdate: false, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '54', + rolloutUserIds: '123', + shouldIncludeUserIds: true, + }, + ], + }); + }); + + describe('scopes', () => { + it('should be possible to remove a scope', () => { + expect(wrapper.find('.js-feature-flag-delete').exists()).toEqual(true); + }); + + it('renders empty row to add a new scope', () => { + expect(wrapper.find('.js-add-new-scope').exists()).toEqual(true); + }); + + it('renders the user id checkbox', () => { + expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); + }); + + it('renders the user id text area', () => { + expect(wrapper.find(GlFormTextarea).exists()).toBe(true); + + expect(wrapper.find(GlFormTextarea).vm.value).toBe('123'); + }); + + describe('update scope', () => { + describe('on click on toggle', () => { + it('should update the scope', () => { + wrapper.find(ToggleButton).vm.$emit('change', false); + + expect(wrapper.vm.formScopes[0].active).toBe(false); + }); + + it('should be disabled if the feature flag is not active', done => { + wrapper.setProps({ active: false }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).toBe(true); + done(); + }); + }); + }); + describe('on strategy change', () => { + it('should not include user IDs if All Users is selected', () => { + const scope = wrapper.find({ ref: 'scopeRow' }); + scope.find('select').setValue(ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(scope.find('#rollout-user-id-0').exists()).toBe(false); + }); + }); + }); + }); + + describe('deleting an existing scope', () => { + beforeEach(() => { + wrapper.find('.js-delete-scope').vm.$emit('click'); + }); + + it('should add `shouldBeDestroyed` key the clicked scope', () => { + expect(wrapper.vm.formScopes[0].shouldBeDestroyed).toBe(true); + }); + + it('should not render deleted scopes', () => { + expect(wrapper.vm.filteredScopes).toEqual([expect.objectContaining({ id: 2 })]); + }); + }); + + describe('deleting a new scope', () => { + it('should remove the scope from formScopes', () => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + description: 'this is a feature flag', + scopes: [ + { + environmentScope: 'new_scope', + active: false, + id: uniqueId(INTERNAL_ID_PREFIX), + canUpdate: true, + protected: false, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + }, + ], + }, + ], + }); + + wrapper.find('.js-delete-scope').vm.$emit('click'); + + expect(wrapper.vm.formScopes).toEqual([]); + }); + }); + + describe('with * scope', () => { + beforeEach(() => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + description: 'this is a feature flag', + scopes: [ + { + environmentScope: '*', + active: false, + canUpdate: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + }, + ], + }); + }); + + it('renders read only name', () => { + expect(wrapper.find('.js-scope-all').exists()).toEqual(true); + }); + }); + + describe('without permission to update', () => { + it('should have the flag name input disabled', () => { + const input = wrapper.find('#feature-flag-name'); + + expect(input.element.disabled).toBe(true); + }); + + it('should have the flag discription text area disabled', () => { + const textarea = wrapper.find('#feature-flag-description'); + + expect(textarea.element.disabled).toBe(true); + }); + + it('should have the scope that cannot be updated be disabled', () => { + const row = wrapper.findAll('.gl-responsive-table-row').at(2); + + expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true); + expect(row.find(ToggleButton).vm.disabledInput).toBe(true); + expect(row.find('.js-delete-scope').exists()).toBe(false); + }); + }); + }); + + describe('on submit', () => { + const selectFirstRolloutStrategyOption = dropdownIndex => { + wrapper + .findAll('select.js-rollout-strategy') + .at(dropdownIndex) + .findAll('option') + .at(1) + .setSelected(); + }; + + beforeEach(() => { + factory({ + ...requiredProps, + name: 'feature_flag_1', + active: true, + description: 'this is a feature flag', + scopes: [ + { + id: 1, + environmentScope: 'production', + canUpdate: true, + protected: true, + active: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }, + ], + }); + + return wrapper.vm.$nextTick(); + }); + + it('should emit handleSubmit with the updated data', () => { + wrapper.find('#feature-flag-name').setValue('feature_flag_2'); + + return wrapper.vm + .$nextTick() + .then(() => { + wrapper + .find('.js-new-scope-name') + .find(EnvironmentsDropdown) + .vm.$emit('selectEnvironment', 'review'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper + .find('.js-add-new-scope') + .find(ToggleButton) + .vm.$emit('change', true); + }) + .then(() => { + wrapper.find(ToggleButton).vm.$emit('change', true); + return wrapper.vm.$nextTick(); + }) + + .then(() => { + selectFirstRolloutStrategyOption(0); + return wrapper.vm.$nextTick(); + }) + .then(() => { + selectFirstRolloutStrategyOption(2); + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper.find('.js-rollout-percentage').setValue('55'); + + return wrapper.vm.$nextTick(); + }) + .then(() => { + wrapper.find({ ref: 'submitButton' }).vm.$emit('click'); + + const data = wrapper.emitted().handleSubmit[0][0]; + + expect(data.name).toEqual('feature_flag_2'); + expect(data.description).toEqual('this is a feature flag'); + expect(data.active).toBe(true); + + expect(data.scopes).toEqual([ + { + id: 1, + active: true, + environmentScope: 'production', + canUpdate: true, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '55', + rolloutUserIds: '', + shouldIncludeUserIds: false, + }, + { + id: expect.any(String), + active: false, + environmentScope: 'review', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }, + { + id: expect.any(String), + active: true, + environmentScope: '', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + shouldIncludeUserIds: false, + }, + ]); + }); + }); + }); + }); + + describe('with strategies', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); + factory({ + ...requiredProps, + name: featureFlag.name, + description: featureFlag.description, + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }, + { + type: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: 'review/*' }], + }, + ], + }); + }); + + it('should request the user lists on mount', () => { + return wrapper.vm.$nextTick(() => { + expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1'); + }); + }); + + it('should show the strategy component', () => { + const strategy = wrapper.find(Strategy); + expect(strategy.exists()).toBe(true); + expect(strategy.props('strategy')).toEqual({ + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }); + }); + + it('should show one strategy component per strategy', () => { + expect(wrapper.findAll(Strategy)).toHaveLength(2); + }); + + it('adds an all users strategy when clicking the Add button', () => { + wrapper.find(GlButton).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + const strategies = wrapper.findAll(Strategy); + + expect(strategies).toHaveLength(3); + expect(strategies.at(2).props('strategy')).toEqual(allUsersStrategy); + }); + }); + + it('should remove a strategy on delete', () => { + const strategy = { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }; + wrapper.find(Strategy).vm.$emit('delete'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(Strategy)).toHaveLength(1); + expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy); + }); + }); + + it('should provide the user lists to the strategy', () => { + expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js new file mode 100644 index 00000000000..12dc98fbde8 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -0,0 +1,105 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +const TEST_HOST = '/test'; +const TEST_SEARCH = 'production'; + +describe('New Environments Dropdown', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + wrapper = shallowMount(NewEnvironmentsDropdown, { + provide: { environmentsEndpoint: TEST_HOST }, + }); + }); + + afterEach(() => { + axiosMock.restore(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('before results', () => { + it('should show a loading icon', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + + it('should not show any dropdown items', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(0); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + }); + + describe('with empty results', () => { + let item; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(200, []); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH); + return axios + .waitForAll() + .then(() => wrapper.vm.$nextTick()) + .then(() => { + item = wrapper.find(GlDropdownItem); + }); + }); + + it('should display a Create item label', () => { + expect(item.text()).toBe('Create production'); + }); + + it('should display that no matching items are found', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true); + }); + + it('should emit a new scope when selected', () => { + item.vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]); + }); + }); + + describe('with results', () => { + let items; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod'); + return axios.waitForAll().then(() => { + items = wrapper.findAll(GlDropdownItem); + }); + }); + + it('should display one item per result', () => { + expect(items).toHaveLength(2); + }); + + it('should emit an add if an item is clicked', () => { + items.at(0).vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([['prod']]); + }); + + it('should not display a create label', () => { + items = items.filter(i => i.text().startsWith('Create')); + expect(items).toHaveLength(0); + }); + + it('should not display a message about no results', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js new file mode 100644 index 00000000000..dbc6e03d922 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -0,0 +1,136 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Form from '~/feature_flags/components/form.vue'; +import createStore from '~/feature_flags/store/new'; +import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + DEFAULT_PERCENT_ROLLOUT, + NEW_FLAG_ALERT, +} from '~/feature_flags/constants'; +import axios from '~/lib/utils/axios_utils'; +import { allUsersStrategy } from '../mock_data'; + +const userCalloutId = 'feature_flags_new_version'; +const userCalloutsPath = `${TEST_HOST}/user_callouts`; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('New feature flag form', () => { + let wrapper; + + const store = createStore({ + endpoint: `${TEST_HOST}/feature_flags.json`, + path: '/feature_flags', + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(NewFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + environmentsEndpoint: 'environments.json', + projectId: '8', + glFeatures: { + featureFlagsNewVersion: true, + }, + ...opts, + }, + }); + }; + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert').exists()).toEqual(true); + expect(wrapper.find('.alert').text()).toContain('The name is required'); + }); + }); + }); + + it('renders form title', () => { + expect(wrapper.find('h3').text()).toEqual('New feature flag'); + }); + + it('should render feature flag form', () => { + expect(wrapper.find(Form).exists()).toEqual(true); + }); + + it('should render default * row', () => { + const defaultScope = { + id: expect.any(String), + environmentScope: '*', + active: true, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + expect(wrapper.vm.scopes).toEqual([defaultScope]); + + expect(wrapper.find(Form).props('scopes')).toContainEqual(defaultScope); + }); + + it('should not alert users that feature flags are changing soon', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('has an all users strategy by default', () => { + const strategies = wrapper.find(Form).props('strategies'); + + expect(strategies).toEqual([allUsersStrategy]); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + factory({ glFeatures: { featureFlagsNewVersion: false } }); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/default_spec.js b/spec/frontend/feature_flags/components/strategies/default_spec.js new file mode 100644 index 00000000000..1315cd7d735 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/default_spec.js @@ -0,0 +1,10 @@ +import { shallowMount } from '@vue/test-utils'; +import Default from '~/feature_flags/components/strategies/default.vue'; + +describe('~/feature_flags/components/strategies/default.vue', () => { + it('should emit an empty parameter object on mount', () => { + const wrapper = shallowMount(Default); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: {} }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js new file mode 100644 index 00000000000..f3f70a325d0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import FlexibleRollout from '~/feature_flags/components/strategies/flexible_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { flexibleRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: flexibleRolloutStrategy, +}; + +describe('feature_flags/components/strategies/flexible_rollout.vue', () => { + let wrapper; + let percentageFormGroup; + let percentageInput; + let stickinessFormGroup; + let stickinessSelect; + + const factory = (props = {}) => + mount(FlexibleRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + percentageFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + percentageInput = percentageFormGroup.find(GlFormInput); + stickinessFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-stickiness"]') + .find(ParameterFormGroup); + stickinessSelect = stickinessFormGroup.find(GlFormSelect); + }); + + it('displays the current percentage value', () => { + expect(percentageInput.element.value).toBe(flexibleRolloutStrategy.parameters.rollout); + }); + + it('displays the current stickiness value', () => { + expect(stickinessSelect.element.value).toBe(flexibleRolloutStrategy.parameters.stickiness); + }); + + it('emits a change when the percentage value changes', async () => { + percentageInput.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: '75', + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: flexibleRolloutStrategy.parameters.stickiness, + }, + }, + ], + ]); + }); + + it('emits a change when the stickiness value changes', async () => { + stickinessSelect.setValue('USERID'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: flexibleRolloutStrategy.parameters.rollout, + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: 'USERID', + }, + }, + ], + ]); + }); + + it('does not show errors', () => { + expect(percentageFormGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '101' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js new file mode 100644 index 00000000000..014c6dd98b9 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import { GlFormSelect } from '@gitlab/ui'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import { userListStrategy, userList } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: userListStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } }); + + describe('with user lists', () => { + beforeEach(() => { + wrapper = factory(); + }); + + it('should show the input for userListId with the correct value', () => { + const inputWrapper = wrapper.find(GlFormSelect); + expect(inputWrapper.exists()).toBe(true); + expect(inputWrapper.element.value).toBe('2'); + }); + + it('should emit a change event when altering the userListId', () => { + const inputWrapper = wrapper.find(GitlabUserList); + inputWrapper.vm.$emit('change', { + userListId: '3', + }); + expect(wrapper.emitted('change')).toEqual([ + [ + { + userListId: '3', + }, + ], + ]); + }); + }); + describe('without user lists', () => { + beforeEach(() => { + wrapper = factory({ userLists: [] }); + }); + + it('should display a message that there are no user lists', () => { + expect(wrapper.text()).toContain('There are no configured user lists'); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js new file mode 100644 index 00000000000..a0ffdb1fca0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; + +describe('~/feature_flags/strategies/parameter_form_group.vue', () => { + let wrapper; + let formGroup; + let slot; + + beforeEach(() => { + wrapper = mount(ParameterFormGroup, { + propsData: { inputId: 'test-id', label: 'test' }, + attrs: { description: 'test description' }, + scopedSlots: { + default(props) { + return this.$createElement(GlFormInput, { + attrs: { id: props.inputId, 'data-testid': 'slot' }, + }); + }, + }, + }); + + formGroup = wrapper.find(GlFormGroup); + slot = wrapper.find('[data-testid="slot"]'); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the default slot', () => { + expect(slot.exists()).toBe(true); + }); + + it('should bind the input id to the slot', () => { + expect(slot.attributes('id')).toBe('test-id'); + }); + + it('should bind the label-for to the input id', () => { + expect(formGroup.find('[for="test-id"]').exists()).toBe(true); + }); + + it('should bind extra attributes to the form group', () => { + expect(formGroup.attributes('description')).toBe('test description'); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js new file mode 100644 index 00000000000..de0b439f1c5 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { percentRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: percentRolloutStrategy, +}; + +describe('~/feature_flags/components/strategies/percent_rollout.vue', () => { + let wrapper; + let input; + let formGroup; + + const factory = (props = {}) => + mount(PercentRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('displays the current value', () => { + expect(input.element.value).toBe(percentRolloutStrategy.parameters.percentage); + }); + + it('emits a change when the value changes', async () => { + input.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [{ parameters: { percentage: '75', groupId: PERCENT_ROLLOUT_GROUP_ID } }], + ]); + }); + + it('does not show errors', () => { + expect(formGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '101' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js new file mode 100644 index 00000000000..460df6ef2ec --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils'; +import { GlFormTextarea } from '@gitlab/ui'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import { usersWithIdStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: usersWithIdStrategy, +}; + +describe('~/feature_flags/components/users_with_id.vue', () => { + let wrapper; + let textarea; + + const factory = (props = {}) => mount(UsersWithId, { propsData: { ...DEFAULT_PROPS, ...props } }); + + beforeEach(() => { + wrapper = factory(); + textarea = wrapper.find(GlFormTextarea); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the current value of the parameters', () => { + expect(textarea.element.value).toBe(usersWithIdStrategy.parameters.userIds); + }); + + it('should emit a change event when the IDs change', () => { + textarea.setValue('4,5,6'); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: { userIds: '4,5,6' } }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js new file mode 100644 index 00000000000..314fb0f21f4 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Default from '~/feature_flags/components/strategies/default.vue'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; +import { allUsersStrategy, userList } from '../mock_data'; + +const DEFAULT_PROPS = { + strategy: allUsersStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategy_parameters.vue', () => { + let wrapper; + + const factory = (props = {}) => + shallowMount(StrategyParameters, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe.each` + name | component + ${ROLLOUT_STRATEGY_ALL_USERS} | ${Default} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${PercentRollout} + ${ROLLOUT_STRATEGY_USER_ID} | ${UsersWithId} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} | ${GitlabUserList} + `('with $name', ({ name, component }) => { + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {} }; + wrapper = factory({ strategy }); + }); + + it('should show the correct component', () => { + expect(wrapper.contains(component)).toBe(true); + }); + + it('should emit changes from the lower component', () => { + const strategyParameterWrapper = wrapper.find(component); + + strategyParameterWrapper.vm.$emit('change', { parameters: { foo: 'bar' } }); + + expect(last(wrapper.emitted('change'))).toEqual([ + { + name, + parameters: { foo: 'bar' }, + }, + ]); + }); + }); + + describe('pass through props', () => { + it('should pass through any extra props that might be needed', () => { + wrapper = factory({ + strategy: { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + }, + }); + + expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js new file mode 100644 index 00000000000..7d6700ba184 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -0,0 +1,264 @@ +import { mount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import { + PERCENT_ROLLOUT_GROUP_ID, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Strategy from '~/feature_flags/components/strategy.vue'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; + +import { userList } from '../mock_data'; + +const provide = { + strategyTypeDocsPagePath: 'link-to-strategy-docs', + environmentsScopeDocsPath: 'link-scope-docs', + environmentsEndpoint: '', +}; + +describe('Feature flags strategy', () => { + let wrapper; + + const findStrategyParameters = () => wrapper.find(StrategyParameters); + const findDocsLinks = () => wrapper.findAll(GlLink); + + const factory = ( + opts = { + propsData: { + strategy: {}, + index: 0, + userLists: [userList], + }, + provide, + }, + ) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = mount(Strategy, opts); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('helper links', () => { + const propsData = { strategy: {}, index: 0, userLists: [userList] }; + factory({ propsData, provide }); + + it('should display 2 helper links', () => { + const links = findDocsLinks(); + expect(links.exists()).toBe(true); + expect(links.at(0).attributes('href')).toContain('docs'); + expect(links.at(1).attributes('href')).toContain('docs'); + }); + }); + + describe.each` + name + ${ROLLOUT_STRATEGY_ALL_USERS} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} + ${ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT} + ${ROLLOUT_STRATEGY_USER_ID} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} + `('with strategy $name', ({ name }) => { + let propsData; + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {}, scopes: [] }; + propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + return wrapper.vm.$nextTick(); + }); + + it('should set the select to match the strategy name', () => { + expect(wrapper.find(GlFormSelect).element.value).toBe(name); + }); + + it('should emit a change if the parameters component does', () => { + findStrategyParameters().vm.$emit('change', { name, parameters: { test: 'parameters' } }); + expect(last(wrapper.emitted('change'))).toEqual([ + { name, parameters: { test: 'parameters' } }, + ]); + }); + }); + + describe('with the gradualRolloutByUserId strategy', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('shows an alert asking users to consider using flexibleRollout instead', () => { + expect(wrapper.find(GlAlert).text()).toContain( + 'Consider using the more flexible "Percent rollout" strategy instead.', + ); + }); + }); + + describe('with a strategy', () => { + describe('with a single environment scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should revert to all-environments scope when last scope is removed', () => { + const token = wrapper.find(GlToken); + token.vm.$emit('close'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(0); + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }, + ]); + }); + }); + }); + + describe('with an all-environments scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should change the parameters if a different strategy is chosen', () => { + const select = wrapper.find(GlFormSelect); + select.setValue(ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environmentScope: '*' }], + }, + ]); + }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [ + { environmentScope: '*', shouldBeDestroyed: true }, + { environmentScope: 'production' }, + ], + }, + ]); + }); + }); + + it('should emit a delete if the delete button is clicked', () => { + wrapper.find(GlButton).vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + + describe('without scopes defined', () => { + beforeEach(() => { + const strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(last(wrapper.emitted('change'))).toEqual([ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: 'production' }], + }, + ]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/feature_flags/components/user_lists_table_spec.js new file mode 100644 index 00000000000..d6ced3be168 --- /dev/null +++ b/spec/frontend/feature_flags/components/user_lists_table_spec.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils'; +import * as timeago from 'timeago.js'; +import { GlModal } from '@gitlab/ui'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import { userList } from '../mock_data'; + +jest.mock('timeago.js', () => ({ + format: jest.fn().mockReturnValue('2 weeks ago'), + register: jest.fn(), +})); + +describe('User Lists Table', () => { + let wrapper; + let userLists; + + beforeEach(() => { + userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i })); + wrapper = mount(UserListsTable, { + propsData: { userLists }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should display the details of a user list', () => { + expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name); + expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe( + userList.user_xids.replace(/,/g, ', '), + ); + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago'); + expect(timeago.format).toHaveBeenCalledWith(userList.created_at); + }); + + it('should set the title for a tooltip on the created stamp', () => { + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( + 'Feb 4, 2020 8:13am GMT+0000', + ); + }); + + it('should display a user list entry per user list', () => { + const lists = wrapper.findAll('[data-testid="ffUserList"]'); + expect(lists).toHaveLength(5); + lists.wrappers.forEach(list => { + expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true); + }); + }); + + describe('edit button', () => { + it('should link to the path for the user list', () => { + expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path); + }); + }); + + describe('delete button', () => { + it('should display the confirmation modal', () => { + const modal = wrapper.find(GlModal); + + wrapper.find('[data-testid="delete-user-list"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(modal.text()).toContain(`Delete ${userList.name}?`); + expect(modal.text()).toContain(`User list ${userList.name} will be removed.`); + }); + }); + }); + + describe('confirmation modal', () => { + let modal; + + beforeEach(() => { + modal = wrapper.find(GlModal); + + wrapper.find('button').trigger('click'); + + return wrapper.vm.$nextTick(); + }); + + it('should emit delete with list on confirmation', () => { + modal.find('[data-testid="modal-confirm"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]); + }); + }); + + it('should not emit delete with list when not confirmed', () => { + modal.find('button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js new file mode 100644 index 00000000000..ed06ea059a7 --- /dev/null +++ b/spec/frontend/feature_flags/mock_data.js @@ -0,0 +1,155 @@ +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + ROLLOUT_STRATEGY_USER_ID, +} from '~/feature_flags/constants'; + +export const featureFlag = { + id: 1, + active: true, + created_at: '2018-12-12T22:07:31.401Z', + updated_at: '2018-12-12T22:07:31.401Z', + name: 'test flag', + description: 'flag for tests', + destroy_path: 'feature_flags/1', + update_path: 'feature_flags/1', + edit_path: 'feature_flags/1/edit', + scopes: [ + { + id: 1, + active: true, + environment_scope: '*', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + }, + ], + }, + { + id: 2, + active: false, + environment_scope: 'production', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + }, + ], + }, + { + id: 3, + active: false, + environment_scope: 'review/*', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + }, + ], + }, + { + id: 4, + active: true, + environment_scope: 'development', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { + percentage: '86', + }, + }, + ], + }, + { + id: 5, + active: true, + environment_scope: 'development', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { + rollout: '42', + stickiness: 'DEFAULT', + }, + }, + ], + }, + ], +}; + +export const getRequestData = { + feature_flags: [featureFlag], + count: { + all: 1, + disabled: 1, + enabled: 0, + }, +}; + +export const rotateData = { token: 'oP6sCNRqtRHmpy1gw2-F' }; + +export const userList = { + name: 'test_users', + user_xids: 'user3,user4,user5', + id: 2, + iid: 2, + project_id: 1, + created_at: '2020-02-04T08:13:10.507Z', + updated_at: '2020-02-04T08:13:10.507Z', + path: '/path/to/user/list', + edit_path: '/path/to/user/list/edit', +}; + +export const userListStrategy = { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + parameters: {}, + scopes: [], + userListId: userList.id, +}; + +export const percentRolloutStrategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [], +}; + +export const flexibleRolloutStrategy = { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { rollout: '50', groupId: 'default', stickiness: 'DEFAULT' }, + scopes: [], +}; + +export const usersWithIdStrategy = { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3' }, + scopes: [], +}; + +export const allUsersStrategy = { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [], +}; diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js new file mode 100644 index 00000000000..9d764799d09 --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -0,0 +1,303 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + updateFeatureFlag, + requestUpdateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + fetchFeatureFlag, + requestFeatureFlag, + receiveFeatureFlagSuccess, + receiveFeatureFlagError, + toggleActive, +} from '~/feature_flags/store/edit/actions'; +import state from '~/feature_flags/store/edit/state'; +import { mapStrategiesToRails, mapFromScopesViewModel } from '~/feature_flags/store/helpers'; +import { + NEW_VERSION_FLAG, + LEGACY_FLAG, + ROLLOUT_STRATEGY_ALL_USERS, +} from '~/feature_flags/constants'; +import * as types from '~/feature_flags/store/edit/mutation_types'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/url_utility'); + +describe('Feature flags Edit Module actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + }); + + describe('updateFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', done => { + const featureFlag = { + name: 'feature_flag', + description: 'feature flag', + scopes: [ + { + id: '1', + environmentScope: '*', + active: true, + shouldBeDestroyed: false, + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + }, + ], + version: LEGACY_FLAG, + active: true, + }; + mock.onPut(mockedState.endpoint, mapFromScopesViewModel(featureFlag)).replyOnce(200); + + testAction( + updateFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'requestUpdateFeatureFlag', + }, + { + type: 'receiveUpdateFeatureFlagSuccess', + }, + ], + done, + ); + }); + it('handles new version flags as well', done => { + const featureFlag = { + name: 'name', + description: 'description', + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + id: 1, + scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }], + shouldBeDestroyed: false, + }, + ], + }; + mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200); + + testAction( + updateFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'requestUpdateFeatureFlag', + }, + { + type: 'receiveUpdateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => { + mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] }); + + testAction( + updateFeatureFlag, + { + name: 'feature_flag', + description: 'feature flag', + scopes: [{ environment_scope: '*', active: true }], + }, + mockedState, + [], + [ + { + type: 'requestUpdateFeatureFlag', + }, + { + type: 'receiveUpdateFeatureFlagError', + payload: { message: [] }, + }, + ], + done, + ); + }); + }); + }); + + describe('requestUpdateFeatureFlag', () => { + it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', done => { + testAction( + requestUpdateFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveUpdateFeatureFlagSuccess', () => { + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveUpdateFeatureFlagSuccess, + null, + mockedState, + [ + { + type: types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUpdateFeatureFlagError', () => { + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveUpdateFeatureFlagError, + 'There was an error', + mockedState, + [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], + [], + done, + ); + }); + }); + + describe('fetchFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 }); + + testAction( + fetchFeatureFlag, + { id: 1 }, + mockedState, + [], + [ + { + type: 'requestFeatureFlag', + }, + { + type: 'receiveFeatureFlagSuccess', + payload: { id: 1 }, + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + fetchFeatureFlag, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlag', + }, + { + type: 'receiveFeatureFlagError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestFeatureFlag', () => { + it('should commit REQUEST_FEATURE_FLAG mutation', done => { + testAction( + requestFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagSuccess', () => { + it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveFeatureFlagSuccess, + { id: 1 }, + mockedState, + [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagError', () => { + it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveFeatureFlagError, + null, + mockedState, + [ + { + type: types.RECEIVE_FEATURE_FLAG_ERROR, + }, + ], + [], + done, + ); + }); + }); + + describe('toggelActive', () => { + it('should commit TOGGLE_ACTIVE mutation', done => { + testAction( + toggleActive, + true, + mockedState, + [{ type: types.TOGGLE_ACTIVE, payload: true }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/edit/mutations_spec.js b/spec/frontend/feature_flags/store/edit/mutations_spec.js new file mode 100644 index 00000000000..1d817fb8004 --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/mutations_spec.js @@ -0,0 +1,134 @@ +import state from '~/feature_flags/store/edit/state'; +import mutations from '~/feature_flags/store/edit/mutations'; +import * as types from '~/feature_flags/store/edit/mutation_types'; + +describe('Feature flags Edit Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + }); + + describe('REQUEST_FEATURE_FLAG', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_FEATURE_FLAG](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + + it('should set error to an empty array', () => { + mutations[types.REQUEST_FEATURE_FLAG](stateCopy); + + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_FEATURE_FLAG_SUCCESS', () => { + const data = { + name: '*', + description: 'All environments', + scopes: [{ id: 1 }], + iid: 5, + version: 'new_version_flag', + strategies: [ + { id: 1, scopes: [{ environment_scope: '*' }], name: 'default', parameters: {} }, + ], + }; + + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAG_SUCCESS](stateCopy, data); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to false', () => { + expect(stateCopy.hasError).toEqual(false); + }); + + it('should set name with the provided one', () => { + expect(stateCopy.name).toEqual(data.name); + }); + + it('should set description with the provided one', () => { + expect(stateCopy.description).toEqual(data.description); + }); + + it('should set scope with the provided one', () => { + expect(stateCopy.scope).toEqual(data.scope); + }); + + it('should set the iid to the provided one', () => { + expect(stateCopy.iid).toEqual(data.iid); + }); + + it('should set the version to the provided one', () => { + expect(stateCopy.version).toBe('new_version_flag'); + }); + + it('should set the strategies to the provided one', () => { + expect(stateCopy.strategies).toEqual([ + { + id: 1, + scopes: [{ environmentScope: '*', shouldBeDestroyed: false }], + name: 'default', + parameters: {}, + shouldBeDestroyed: false, + }, + ]); + }); + }); + + describe('RECEIVE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAG_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_UPDATE_FEATURE_FLAG', () => { + beforeEach(() => { + mutations[types.REQUEST_UPDATE_FEATURE_FLAG](stateCopy); + }); + + it('should set isSendingRequest to true', () => { + expect(stateCopy.isSendingRequest).toEqual(true); + }); + + it('should set error to an empty array', () => { + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => { + it('should set isSendingRequest to false', () => { + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(false); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, { + message: ['Name is required'], + }); + }); + + it('should set isSendingRequest to false', () => { + expect(stateCopy.isSendingRequest).toEqual(false); + }); + + it('should set error to the given message', () => { + expect(stateCopy.error).toEqual(['Name is required']); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/helpers_spec.js b/spec/frontend/feature_flags/store/helpers_spec.js new file mode 100644 index 00000000000..301b1d09fcc --- /dev/null +++ b/spec/frontend/feature_flags/store/helpers_spec.js @@ -0,0 +1,514 @@ +import { uniqueId } from 'lodash'; +import { + mapToScopesViewModel, + mapFromScopesViewModel, + createNewEnvironmentScope, + mapStrategiesToViewModel, + mapStrategiesToRails, +} from '~/feature_flags/store/helpers'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + PERCENT_ROLLOUT_GROUP_ID, + INTERNAL_ID_PREFIX, + DEFAULT_PERCENT_ROLLOUT, + LEGACY_FLAG, + NEW_VERSION_FLAG, +} from '~/feature_flags/constants'; + +describe('feature flags helpers spec', () => { + describe('mapToScopesViewModel', () => { + it('converts the data object from the Rails API into something more usable by Vue', () => { + const input = [ + { + id: 3, + environment_scope: 'environment_scope', + active: true, + can_update: true, + protected: true, + strategies: [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { + percentage: '56', + }, + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { + userIds: '123,234', + }, + }, + ], + + _destroy: true, + }, + ]; + + const expected = [ + expect.objectContaining({ + id: 3, + environmentScope: 'environment_scope', + active: true, + canUpdate: true, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '56', + rolloutUserIds: '123, 234', + shouldBeDestroyed: true, + }), + ]; + + const actual = mapToScopesViewModel(input); + + expect(actual).toEqual(expected); + }); + + it('returns Boolean properties even when their Rails counterparts were not provided (are `undefined`)', () => { + const input = [ + { + id: 3, + environment_scope: 'environment_scope', + }, + ]; + + const [result] = mapToScopesViewModel(input); + + expect(result).toEqual( + expect.objectContaining({ + active: false, + canUpdate: false, + protected: false, + shouldBeDestroyed: false, + }), + ); + }); + + it('returns an empty array if null or undefined is provided as a parameter', () => { + expect(mapToScopesViewModel(null)).toEqual([]); + expect(mapToScopesViewModel(undefined)).toEqual([]); + }); + + describe('with user IDs per environment', () => { + let oldGon; + + beforeEach(() => { + oldGon = window.gon; + window.gon = { features: { featureFlagsUsersPerEnvironment: true } }; + }); + + afterEach(() => { + window.gon = oldGon; + }); + + it('sets the user IDs as a comma separated string', () => { + const input = [ + { + id: 3, + environment_scope: 'environment_scope', + active: true, + can_update: true, + protected: true, + strategies: [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { + percentage: '56', + }, + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { + userIds: '123,234', + }, + }, + ], + + _destroy: true, + }, + ]; + + const expected = [ + { + id: 3, + environmentScope: 'environment_scope', + active: true, + canUpdate: true, + protected: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '56', + rolloutUserIds: '123, 234', + shouldBeDestroyed: true, + shouldIncludeUserIds: true, + }, + ]; + + const actual = mapToScopesViewModel(input); + + expect(actual).toEqual(expected); + }); + }); + }); + + describe('mapFromScopesViewModel', () => { + it('converts the object emitted from the Vue component into an object than is in the right format to be submitted to the Rails API', () => { + const input = { + name: 'name', + description: 'description', + active: true, + scopes: [ + { + id: 4, + environmentScope: 'environmentScope', + active: true, + canUpdate: true, + protected: true, + shouldBeDestroyed: true, + shouldIncludeUserIds: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '48', + rolloutUserIds: '123, 234', + }, + ], + }; + + const expected = { + operations_feature_flag: { + name: 'name', + description: 'description', + active: true, + version: LEGACY_FLAG, + scopes_attributes: [ + { + id: 4, + environment_scope: 'environmentScope', + active: true, + can_update: true, + protected: true, + _destroy: true, + strategies: [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + percentage: '48', + }, + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { + userIds: '123,234', + }, + }, + ], + }, + ], + }, + }; + + const actual = mapFromScopesViewModel(input); + + expect(actual).toEqual(expected); + }); + + it('should strip out internal IDs', () => { + const input = { + scopes: [{ id: 3 }, { id: uniqueId(INTERNAL_ID_PREFIX) }], + }; + + const result = mapFromScopesViewModel(input); + const [realId, internalId] = result.operations_feature_flag.scopes_attributes; + + expect(realId.id).toBe(3); + expect(internalId.id).toBeUndefined(); + }); + + it('returns scopes_attributes as [] if param.scopes is null or undefined', () => { + let { + operations_feature_flag: { scopes_attributes: actualScopes }, + } = mapFromScopesViewModel({ scopes: null }); + + expect(actualScopes).toEqual([]); + + ({ + operations_feature_flag: { scopes_attributes: actualScopes }, + } = mapFromScopesViewModel({ scopes: undefined })); + + expect(actualScopes).toEqual([]); + }); + describe('with user IDs per environment', () => { + it('sets the user IDs as a comma separated string', () => { + const input = { + name: 'name', + description: 'description', + active: true, + scopes: [ + { + id: 4, + environmentScope: 'environmentScope', + active: true, + canUpdate: true, + protected: true, + shouldBeDestroyed: true, + rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + rolloutPercentage: '48', + rolloutUserIds: '123, 234', + shouldIncludeUserIds: true, + }, + ], + }; + + const expected = { + operations_feature_flag: { + name: 'name', + description: 'description', + version: LEGACY_FLAG, + active: true, + scopes_attributes: [ + { + id: 4, + environment_scope: 'environmentScope', + active: true, + can_update: true, + protected: true, + _destroy: true, + strategies: [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + percentage: '48', + }, + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { + userIds: '123,234', + }, + }, + ], + }, + ], + }, + }; + + const actual = mapFromScopesViewModel(input); + + expect(actual).toEqual(expected); + }); + }); + }); + + describe('createNewEnvironmentScope', () => { + it('should return a new environment scope object populated with the default options', () => { + const expected = { + environmentScope: '', + active: false, + id: expect.stringContaining(INTERNAL_ID_PREFIX), + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + + const actual = createNewEnvironmentScope(); + + expect(actual).toEqual(expected); + }); + + it('should return a new environment scope object with overrides applied', () => { + const overrides = { + environmentScope: 'environmentScope', + active: true, + }; + + const expected = { + environmentScope: 'environmentScope', + active: true, + id: expect.stringContaining(INTERNAL_ID_PREFIX), + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + rolloutUserIds: '', + }; + + const actual = createNewEnvironmentScope(overrides); + + expect(actual).toEqual(expected); + }); + + it('sets canUpdate and protected when called with featureFlagPermissions=true', () => { + expect(createNewEnvironmentScope({}, true)).toEqual( + expect.objectContaining({ + canUpdate: true, + protected: false, + }), + ); + }); + }); + + describe('mapStrategiesToViewModel', () => { + it('should map rails casing to view model casing', () => { + expect( + mapStrategiesToViewModel([ + { + id: '1', + name: 'default', + parameters: {}, + scopes: [ + { + environment_scope: '*', + id: '1', + }, + ], + }, + ]), + ).toEqual([ + { + id: '1', + name: 'default', + parameters: {}, + shouldBeDestroyed: false, + scopes: [ + { + shouldBeDestroyed: false, + environmentScope: '*', + id: '1', + }, + ], + }, + ]); + }); + + it('inserts spaces between user ids', () => { + const strategy = mapStrategiesToViewModel([ + { + id: '1', + name: 'userWithId', + parameters: { userIds: 'user1,user2,user3' }, + scopes: [], + }, + ])[0]; + + expect(strategy.parameters).toEqual({ userIds: 'user1, user2, user3' }); + }); + }); + + describe('mapStrategiesToRails', () => { + it('should map rails casing to view model casing', () => { + expect( + mapStrategiesToRails({ + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'default', + parameters: {}, + shouldBeDestroyed: true, + scopes: [ + { + environmentScope: '*', + id: '1', + shouldBeDestroyed: true, + }, + ], + }, + ], + }), + ).toEqual({ + operations_feature_flag: { + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies_attributes: [ + { + id: '1', + name: 'default', + parameters: {}, + _destroy: true, + scopes_attributes: [ + { + environment_scope: '*', + id: '1', + _destroy: true, + }, + ], + }, + ], + }, + }); + }); + + it('should insert a default * scope if there are none', () => { + expect( + mapStrategiesToRails({ + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'default', + parameters: {}, + scopes: [], + }, + ], + }), + ).toEqual({ + operations_feature_flag: { + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies_attributes: [ + { + id: '1', + name: 'default', + parameters: {}, + scopes_attributes: [ + { + environment_scope: '*', + }, + ], + }, + ], + }, + }); + }); + + it('removes white space between user ids', () => { + const result = mapStrategiesToRails({ + name: 'test', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'userWithId', + parameters: { userIds: 'user1, user2, user3' }, + scopes: [], + }, + ], + }); + + const strategyAttrs = result.operations_feature_flag.strategies_attributes[0]; + + expect(strategyAttrs.parameters).toEqual({ userIds: 'user1,user2,user3' }); + }); + + it('preserves the value of active', () => { + const result = mapStrategiesToRails({ + name: 'test', + version: NEW_VERSION_FLAG, + active: false, + strategies: [], + }); + + expect(result.operations_feature_flag.active).toBe(false); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js new file mode 100644 index 00000000000..d223bb2c292 --- /dev/null +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -0,0 +1,563 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import { + requestFeatureFlags, + receiveFeatureFlagsSuccess, + receiveFeatureFlagsError, + fetchFeatureFlags, + setFeatureFlagsOptions, + rotateInstanceId, + requestRotateInstanceId, + receiveRotateInstanceIdSuccess, + receiveRotateInstanceIdError, + toggleFeatureFlag, + updateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + requestUserLists, + receiveUserListsSuccess, + receiveUserListsError, + fetchUserLists, + deleteUserList, + receiveDeleteUserListError, + clearAlert, +} from '~/feature_flags/store/index/actions'; +import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; +import state from '~/feature_flags/store/index/state'; +import * as types from '~/feature_flags/store/index/mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; + +jest.mock('~/api.js'); + +describe('Feature flags actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state({}); + }); + + describe('setFeatureFlagsOptions', () => { + it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', done => { + testAction( + setFeatureFlagsOptions, + { page: '1', scope: 'all' }, + mockedState, + [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }], + [], + done, + ); + }); + }); + + describe('fetchFeatureFlags', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {}); + + testAction( + fetchFeatureFlags, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlags', + }, + { + payload: { data: getRequestData, headers: {} }, + type: 'receiveFeatureFlagsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + fetchFeatureFlags, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlags', + }, + { + type: 'receiveFeatureFlagsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestFeatureFlags', () => { + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => { + testAction( + requestFeatureFlags, + null, + mockedState, + [{ type: types.REQUEST_FEATURE_FLAGS }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagsSuccess', () => { + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => { + testAction( + receiveFeatureFlagsSuccess, + { data: getRequestData, headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_FEATURE_FLAGS_SUCCESS, + payload: { data: getRequestData, headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagsError', () => { + it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', done => { + testAction( + receiveFeatureFlagsError, + null, + mockedState, + [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchUserLists', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} }); + }); + + describe('success', () => { + it('dispatches requestUserLists and receiveUserListsSuccess ', done => { + testAction( + fetchUserLists, + null, + mockedState, + [], + [ + { + type: 'requestUserLists', + }, + { + payload: { data: [userList], headers: {} }, + type: 'receiveUserListsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUserLists and receiveUserListsError ', done => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + + testAction( + fetchUserLists, + null, + mockedState, + [], + [ + { + type: 'requestUserLists', + }, + { + type: 'receiveUserListsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestUserLists', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => { + testAction( + requestUserLists, + null, + mockedState, + [{ type: types.REQUEST_USER_LISTS }], + [], + done, + ); + }); + }); + + describe('receiveUserListsSuccess', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => { + testAction( + receiveUserListsSuccess, + { data: [userList], headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_USER_LISTS_SUCCESS, + payload: { data: [userList], headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUserListsError', () => { + it('should commit RECEIVE_USER_LISTS_ERROR mutation', done => { + testAction( + receiveUserListsError, + null, + mockedState, + [{ type: types.RECEIVE_USER_LISTS_ERROR }], + [], + done, + ); + }); + }); + + describe('rotateInstanceId', () => { + let mock; + + beforeEach(() => { + mockedState.rotateEndpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => { + mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); + + testAction( + rotateInstanceId, + null, + mockedState, + [], + [ + { + type: 'requestRotateInstanceId', + }, + { + payload: { data: rotateData, headers: {} }, + type: 'receiveRotateInstanceIdSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + rotateInstanceId, + null, + mockedState, + [], + [ + { + type: 'requestRotateInstanceId', + }, + { + type: 'receiveRotateInstanceIdError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestRotateInstanceId', () => { + it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', done => { + testAction( + requestRotateInstanceId, + null, + mockedState, + [{ type: types.REQUEST_ROTATE_INSTANCE_ID }], + [], + done, + ); + }); + }); + + describe('receiveRotateInstanceIdSuccess', () => { + it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', done => { + testAction( + receiveRotateInstanceIdSuccess, + { data: rotateData, headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, + payload: { data: rotateData, headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveRotateInstanceIdError', () => { + it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', done => { + testAction( + receiveRotateInstanceIdError, + null, + mockedState, + [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }], + [], + done, + ); + }); + }); + + describe('toggleFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('success', () => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => { + mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {}); + + testAction( + toggleFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'updateFeatureFlag', + payload: featureFlag, + }, + { + payload: featureFlag, + type: 'receiveUpdateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + describe('error', () => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => { + mock.onPut(featureFlag.update_path).replyOnce(500); + + testAction( + toggleFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'updateFeatureFlag', + payload: featureFlag, + }, + { + payload: featureFlag.id, + type: 'receiveUpdateFeatureFlagError', + }, + ], + done, + ); + }); + }); + }); + describe('updateFeatureFlag', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits UPDATE_FEATURE_FLAG with the given flag', done => { + testAction( + updateFeatureFlag, + featureFlag, + mockedState, + [ + { + type: 'UPDATE_FEATURE_FLAG', + payload: featureFlag, + }, + ], + [], + done, + ); + }); + }); + describe('receiveUpdateFeatureFlagSuccess', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', done => { + testAction( + receiveUpdateFeatureFlagSuccess, + featureFlag, + mockedState, + [ + { + type: 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', + payload: featureFlag, + }, + ], + [], + done, + ); + }); + }); + describe('receiveUpdateFeatureFlagError', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', done => { + testAction( + receiveUpdateFeatureFlagError, + featureFlag.id, + mockedState, + [ + { + type: 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR', + payload: featureFlag.id, + }, + ], + [], + done, + ); + }); + }); + describe('deleteUserList', () => { + beforeEach(() => { + mockedState.userLists = [userList]; + }); + + describe('success', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockResolvedValue(); + }); + + it('should refresh the user lists', done => { + testAction( + deleteUserList, + userList, + mockedState, + [], + [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); + }); + + it('should dispatch receiveDeleteUserListError', done => { + testAction( + deleteUserList, + userList, + mockedState, + [], + [ + { type: 'requestDeleteUserList', payload: userList }, + { + type: 'receiveDeleteUserListError', + payload: { list: userList, error: 'some error' }, + }, + ], + done, + ); + }); + }); + }); + + describe('receiveDeleteUserListError', () => { + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', done => { + testAction( + receiveDeleteUserListError, + { list: userList, error: 'mock error' }, + mockedState, + [ + { + type: 'RECEIVE_DELETE_USER_LIST_ERROR', + payload: { list: userList, error: 'mock error' }, + }, + ], + [], + done, + ); + }); + }); + + describe('clearAlert', () => { + it('should commit RECEIVE_CLEAR_ALERT', done => { + const alertIndex = 3; + + testAction( + clearAlert, + alertIndex, + mockedState, + [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js new file mode 100644 index 00000000000..376c7b069fa --- /dev/null +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -0,0 +1,307 @@ +import state from '~/feature_flags/store/index/state'; +import mutations from '~/feature_flags/store/index/mutations'; +import * as types from '~/feature_flags/store/index/mutation_types'; +import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; + +describe('Feature flags store Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state({}); + }); + + describe('SET_FEATURE_FLAGS_OPTIONS', () => { + it('should set provided options', () => { + mutations[types.SET_FEATURE_FLAGS_OPTIONS](stateCopy, { page: '1', scope: 'all' }); + + expect(stateCopy.options).toEqual({ page: '1', scope: 'all' }); + }); + }); + describe('REQUEST_FEATURE_FLAGS', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_FEATURE_FLAGS](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_FEATURE_FLAGS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAGS_SUCCESS](stateCopy, { data: getRequestData, headers }); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to false', () => { + expect(stateCopy.hasError).toEqual(false); + }); + + it('should set featureFlags with the transformed data', () => { + const expected = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + + expect(stateCopy.featureFlags).toEqual(expected); + }); + + it('should set count with the given data', () => { + expect(stateCopy.count.featureFlags).toEqual(37); + }); + + it('should set pagination', () => { + expect(stateCopy.pageInfo.featureFlags).toEqual( + parseIntPagination(normalizeHeaders(headers)), + ); + }); + }); + + describe('RECEIVE_FEATURE_FLAGS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAGS_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_USER_LISTS', () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_USER_LISTS](stateCopy); + expect(stateCopy.isLoading).toBe(true); + }); + }); + + describe('RECEIVE_USER_LISTS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers }); + }); + + it('sets isLoading to false', () => { + expect(stateCopy.isLoading).toBe(false); + }); + + it('sets userLists to the received userLists', () => { + expect(stateCopy.userLists).toEqual([userList]); + }); + + it('sets pagination info for user lits', () => { + expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers))); + }); + + it('sets the count for user lists', () => { + expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10)); + }); + }); + + describe('RECEIVE_USER_LISTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_ROTATE_INSTANCE_ID', () => { + beforeEach(() => { + mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy); + }); + + it('should set isRotating to true', () => { + expect(stateCopy.isRotating).toBe(true); + }); + + it('should set hasRotateError to false', () => { + expect(stateCopy.hasRotateError).toBe(false); + }); + }); + + describe('RECEIVE_ROTATE_INSTANCE_ID_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](stateCopy, { data: rotateData }); + }); + + it('should set the instance id to the received data', () => { + expect(stateCopy.instanceId).toBe(rotateData.token); + }); + + it('should set isRotating to false', () => { + expect(stateCopy.isRotating).toBe(false); + }); + + it('should set hasRotateError to false', () => { + expect(stateCopy.hasRotateError).toBe(false); + }); + }); + + describe('RECEIVE_ROTATE_INSTANCE_ID_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](stateCopy); + }); + + it('should set isRotating to false', () => { + expect(stateCopy.isRotating).toBe(false); + }); + + it('should set hasRotateError to true', () => { + expect(stateCopy.hasRotateError).toBe(true); + }); + }); + + describe('UPDATE_FEATURE_FLAG', () => { + beforeEach(() => { + stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + stateCopy.count = { featureFlags: 1, userLists: 0 }; + + mutations[types.UPDATE_FEATURE_FLAG](stateCopy, { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }); + }); + + it('should update the flag with the matching ID', () => { + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => { + const runUpdate = (stateCount, flagState, featureFlagUpdateParams) => { + stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + ...flagState, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + stateCopy.count.featureFlags = stateCount; + + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, { + ...featureFlag, + ...featureFlagUpdateParams, + }); + }; + + it('updates the flag with the matching ID', () => { + runUpdate({ all: 1, enabled: 1, disabled: 0 }, { active: true }, { active: false }); + + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + stateCopy.count = { enabled: 1, disabled: 0 }; + + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id); + }); + + it('should update the flag with the matching ID, toggling active', () => { + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('REQUEST_DELETE_USER_LIST', () => { + beforeEach(() => { + stateCopy.userLists = [userList]; + mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList); + }); + + it('should remove the deleted list', () => { + expect(stateCopy.userLists).not.toContain(userList); + }); + }); + + describe('RECEIVE_DELETE_USER_LIST_ERROR', () => { + beforeEach(() => { + stateCopy.userLists = []; + mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, { + list: userList, + error: 'some error', + }); + }); + + it('should set isLoading to false and hasError to false', () => { + expect(stateCopy.isLoading).toBe(false); + expect(stateCopy.hasError).toBe(false); + }); + + it('should add the user list back to the list of user lists', () => { + expect(stateCopy.userLists).toContain(userList); + }); + }); + + describe('RECEIVE_CLEAR_ALERT', () => { + it('clears the alert', () => { + stateCopy.alerts = ['a server error']; + + mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 0); + + expect(stateCopy.alerts).toEqual([]); + }); + + it('clears the alert at the specified index', () => { + stateCopy.alerts = ['a server error', 'another error', 'final error']; + + mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 1); + + expect(stateCopy.alerts).toEqual(['a server error', 'final error']); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js new file mode 100644 index 00000000000..130c5235aa0 --- /dev/null +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -0,0 +1,192 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + createFeatureFlag, + requestCreateFeatureFlag, + receiveCreateFeatureFlagSuccess, + receiveCreateFeatureFlagError, +} from '~/feature_flags/store/new/actions'; +import state from '~/feature_flags/store/new/state'; +import * as types from '~/feature_flags/store/new/mutation_types'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + LEGACY_FLAG, + NEW_VERSION_FLAG, +} from '~/feature_flags/constants'; +import { mapFromScopesViewModel, mapStrategiesToRails } from '~/feature_flags/store/helpers'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/url_utility'); + +describe('Feature flags New Module Actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + }); + + describe('createFeatureFlag', () => { + let mock; + + const actionParams = { + name: 'name', + description: 'description', + active: true, + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + environmentScope: 'environmentScope', + active: true, + canUpdate: true, + protected: true, + shouldBeDestroyed: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + }, + ], + }; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', done => { + const convertedActionParams = mapFromScopesViewModel(actionParams); + + mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200); + + testAction( + createFeatureFlag, + actionParams, + mockedState, + [], + [ + { + type: 'requestCreateFeatureFlag', + }, + { + type: 'receiveCreateFeatureFlagSuccess', + }, + ], + done, + ); + }); + + it('sends strategies for new style feature flags', done => { + const newVersionFlagParams = { + name: 'name', + description: 'description', + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + id: 1, + scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }], + shouldBeDestroyed: false, + }, + ], + }; + mock + .onPost(`${TEST_HOST}/endpoint.json`, mapStrategiesToRails(newVersionFlagParams)) + .replyOnce(200); + + testAction( + createFeatureFlag, + newVersionFlagParams, + mockedState, + [], + [ + { + type: 'requestCreateFeatureFlag', + }, + { + type: 'receiveCreateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => { + const convertedActionParams = mapFromScopesViewModel(actionParams); + + mock + .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams) + .replyOnce(500, { message: [] }); + + testAction( + createFeatureFlag, + actionParams, + mockedState, + [], + [ + { + type: 'requestCreateFeatureFlag', + }, + { + type: 'receiveCreateFeatureFlagError', + payload: { message: [] }, + }, + ], + done, + ); + }); + }); + }); + + describe('requestCreateFeatureFlag', () => { + it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', done => { + testAction( + requestCreateFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_CREATE_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveCreateFeatureFlagSuccess', () => { + it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveCreateFeatureFlagSuccess, + null, + mockedState, + [ + { + type: types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveCreateFeatureFlagError', () => { + it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveCreateFeatureFlagError, + 'There was an error', + mockedState, + [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/new/mutations_spec.js b/spec/frontend/feature_flags/store/new/mutations_spec.js new file mode 100644 index 00000000000..e8609a6d116 --- /dev/null +++ b/spec/frontend/feature_flags/store/new/mutations_spec.js @@ -0,0 +1,49 @@ +import state from '~/feature_flags/store/new/state'; +import mutations from '~/feature_flags/store/new/mutations'; +import * as types from '~/feature_flags/store/new/mutation_types'; + +describe('Feature flags New Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state({ endpoint: 'feature_flags.json', path: 'feature_flags' }); + }); + + describe('REQUEST_CREATE_FEATURE_FLAG', () => { + it('should set isSendingRequest to true', () => { + mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(true); + }); + + it('should set error to an empty array', () => { + mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy); + + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_CREATE_FEATURE_FLAG_SUCCESS', () => { + it('should set isSendingRequest to false', () => { + mutations[types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(false); + }); + }); + + describe('RECEIVE_CREATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](stateCopy, { + message: ['Name is required'], + }); + }); + + it('should set isSendingRequest to false', () => { + expect(stateCopy.isSendingRequest).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.error).toEqual(['Name is required']); + }); + }); +}); |