diff options
Diffstat (limited to 'spec/frontend/releases')
15 files changed, 1060 insertions, 356 deletions
diff --git a/spec/frontend/releases/components/app_edit_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 4450b047acd..e9727801c1a 100644 --- a/spec/frontend/releases/components/app_edit_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -1,15 +1,15 @@ import Vuex from 'vuex'; import { mount } from '@vue/test-utils'; -import ReleaseEditApp from '~/releases/components/app_edit.vue'; +import { merge } from 'lodash'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import { release as originalRelease, milestones as originalMilestones } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; -import { merge } from 'lodash'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -describe('Release edit component', () => { +describe('Release edit/new component', () => { let wrapper; let release; let actions; @@ -27,13 +27,14 @@ describe('Release edit component', () => { }; actions = { - fetchRelease: jest.fn(), - updateRelease: jest.fn(), + initializeRelease: jest.fn(), + saveRelease: jest.fn(), addEmptyAssetLink: jest.fn(), }; getters = { isValid: () => true, + isExistingRelease: () => true, validationErrors: () => ({ assets: { links: [], @@ -57,12 +58,14 @@ describe('Release edit component', () => { ), ); - wrapper = mount(ReleaseEditApp, { + wrapper = mount(ReleaseEditNewApp, { store, provide: { glFeatures: featureFlags, }, }); + + wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus')); }; beforeEach(() => { @@ -80,14 +83,23 @@ describe('Release edit component', () => { }); const findSubmitButton = () => wrapper.find('button[type=submit]'); + const findForm = () => wrapper.find('form'); describe(`basic functionality tests: all tests unrelated to the "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(() => { - factory(); + beforeEach(factory); + + it('calls initializeRelease when the component is created', () => { + expect(actions.initializeRelease).toHaveBeenCalledTimes(1); }); - it('calls fetchRelease when the component is created', () => { - expect(actions.fetchRelease).toHaveBeenCalledTimes(1); + it('focuses the first non-disabled input element once the page is shown', () => { + const firstEnabledInput = wrapper.element.querySelector('input:enabled'); + const allInputs = wrapper.element.querySelectorAll('input'); + + allInputs.forEach(input => { + const expectedFocusCalls = input === firstEnabledInput ? 1 : 0; + expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls); + }); }); it('renders the description text at the top of the page', () => { @@ -96,28 +108,6 @@ describe('Release edit component', () => { ); }); - it('renders the correct tag name in the "Tag name" field', () => { - expect(wrapper.find('#git-ref').element.value).toBe(release.tagName); - }); - - it('renders the correct help text under the "Tag name" field', () => { - const helperText = wrapper.find('#tag-name-help'); - const helperTextLink = helperText.find('a'); - const helperTextLinkAttrs = helperTextLink.attributes(); - - expect(helperText.text()).toBe( - 'Changing a Release tag is only supported via Releases API. More information', - ); - expect(helperTextLink.text()).toBe('More information'); - expect(helperTextLinkAttrs).toEqual( - expect.objectContaining({ - href: state.updateReleaseApiDocsPath, - rel: 'noopener noreferrer', - target: '_blank', - }), - ); - }); - it('renders the correct release title in the "Release title" field', () => { expect(wrapper.find('#release-title').element.value).toBe(release.name); }); @@ -130,16 +120,15 @@ describe('Release edit component', () => { expect(findSubmitButton().attributes('type')).toBe('submit'); }); - it('calls updateRelease when the form is submitted', () => { - wrapper.find('form').trigger('submit'); - expect(actions.updateRelease).toHaveBeenCalledTimes(1); + it('calls saveRelease when the form is submitted', () => { + findForm().trigger('submit'); + + expect(actions.saveRelease).toHaveBeenCalledTimes(1); }); }); describe(`when the URL does not contain a "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(() => { - factory(); - }); + beforeEach(factory); it(`renders a "Cancel" button with an href pointing to "${BACK_URL_PARAM}"`, () => { const cancelButton = wrapper.find('.js-cancel-button'); @@ -164,6 +153,34 @@ describe('Release edit component', () => { }); }); + describe('when creating a new release', () => { + beforeEach(() => { + factory({ + store: { + modules: { + detail: { + getters: { + isExistingRelease: () => false, + }, + }, + }, + }, + }); + }); + + it('renders the submit button with the text "Create release"', () => { + expect(findSubmitButton().text()).toBe('Create release'); + }); + }); + + describe('when editing an existing release', () => { + beforeEach(factory); + + it('renders the submit button with the text "Save changes"', () => { + expect(findSubmitButton().text()).toBe('Save changes'); + }); + }); + describe('asset links form', () => { const findAssetLinksForm = () => wrapper.find(AssetLinksForm); @@ -227,6 +244,12 @@ describe('Release edit component', () => { it('renders the submit button as disabled', () => { expect(findSubmitButton().attributes('disabled')).toBe('disabled'); }); + + it('does not allow the form to be submitted', () => { + findForm().trigger('submit'); + + expect(actions.saveRelease).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 91beb5b1418..8eafe07cb2f 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,6 +1,7 @@ import { range as rge } from 'lodash'; import Vue from 'vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import app from '~/releases/components/app_index.vue'; import createStore from '~/releases/stores'; import listModule from '~/releases/stores/modules/list'; @@ -13,7 +14,6 @@ import { releases, } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import waitForPromises from 'helpers/wait_for_promises'; describe('Releases App ', () => { const Component = Vue.extend(app); diff --git a/spec/frontend/releases/components/app_new_spec.js b/spec/frontend/releases/components/app_new_spec.js deleted file mode 100644 index 0d5664766e5..00000000000 --- a/spec/frontend/releases/components/app_new_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; -import ReleaseNewApp from '~/releases/components/app_new.vue'; - -Vue.use(Vuex); - -describe('Release new component', () => { - let wrapper; - - const factory = () => { - const store = new Vuex.Store(); - wrapper = mount(ReleaseNewApp, { store }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders the app', () => { - factory(); - - expect(wrapper.exists()).toBe(true); - }); -}); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 3dc9964c25c..e757fe98661 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,8 +1,8 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; +import { GlSkeletonLoading } from '@gitlab/ui'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import { release as originalRelease } from '../mock_data'; -import { GlSkeletonLoading } from '@gitlab/ui'; import ReleaseBlock from '~/releases/components/release_block.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index e1f8592270e..727d593d851 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -3,6 +3,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { release as originalRelease } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; const localVue = createLocalVue(); @@ -91,42 +92,128 @@ describe('Release edit component', () => { expect(actions.removeAssetLink).toHaveBeenCalledTimes(1); }); - it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => { - const linkIdToUpdate = release.assets.links[0].id; - const newUrl = 'updated url'; + describe('URL input field', () => { + let input; + let linkIdToUpdate; + let newUrl; - expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + beforeEach(() => { + input = wrapper.find({ ref: 'urlInput' }).element; + linkIdToUpdate = release.assets.links[0].id; + newUrl = 'updated url'; + }); - wrapper.find({ ref: 'urlInput' }).vm.$emit('change', newUrl); + const expectStoreMethodNotToBeCalled = () => { + expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + }; - expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newUrl, - }, - undefined, - ); + const dispatchKeydowEvent = eventParams => { + const event = new KeyboardEvent('keydown', eventParams); + + input.dispatchEvent(event); + }; + + const expectStoreMethodToBeCalled = () => { + expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1); + expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith( + expect.anything(), + { + linkIdToUpdate, + newUrl, + }, + undefined, + ); + }; + + it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + wrapper.find({ ref: 'urlInput' }).vm.$emit('change', newUrl); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkUrl" store method when Ctrl+Enter is pressed inside the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newUrl; + + dispatchKeydowEvent({ key: ENTER_KEY, ctrlKey: true }); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkUrl" store method when Cmd+Enter is pressed inside the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newUrl; + + dispatchKeydowEvent({ key: ENTER_KEY, metaKey: true }); + + expectStoreMethodToBeCalled(); + }); }); - it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => { - const linkIdToUpdate = release.assets.links[0].id; - const newName = 'updated name'; + describe('Link title field', () => { + let input; + let linkIdToUpdate; + let newName; - expect(actions.updateAssetLinkName).not.toHaveBeenCalled(); + beforeEach(() => { + input = wrapper.find({ ref: 'nameInput' }).element; + linkIdToUpdate = release.assets.links[0].id; + newName = 'updated name'; + }); - wrapper.find({ ref: 'nameInput' }).vm.$emit('change', newName); + const expectStoreMethodNotToBeCalled = () => { + expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + }; - expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkName).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newName, - }, - undefined, - ); + const dispatchKeydowEvent = eventParams => { + const event = new KeyboardEvent('keydown', eventParams); + + input.dispatchEvent(event); + }; + + const expectStoreMethodToBeCalled = () => { + expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1); + expect(actions.updateAssetLinkName).toHaveBeenCalledWith( + expect.anything(), + { + linkIdToUpdate, + newName, + }, + undefined, + ); + }; + + it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + wrapper.find({ ref: 'nameInput' }).vm.$emit('change', newName); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkName" store method when Ctrl+Enter is pressed inside the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newName; + + dispatchKeydowEvent({ key: ENTER_KEY, ctrlKey: true }); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkName" store method when Cmd+Enter is pressed inside the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newName; + + dispatchKeydowEvent({ key: ENTER_KEY, metaKey: true }); + + expectStoreMethodToBeCalled(); + }); }); it('calls the "updateAssetLinkType" store method when an option is selected from the "Type" dropdown', () => { diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index a85532a8118..5e84290716c 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -1,10 +1,10 @@ import { mount } from '@vue/test-utils'; import { GlCollapse } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { cloneDeep } from 'lodash'; import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -import { trimText } from 'helpers/text_helper'; import { assets } from '../mock_data'; -import { cloneDeep } from 'lodash'; describe('Release block assets', () => { let wrapper; diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index b91cfb82b65..c066bfbf020 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,11 +1,11 @@ import { mount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; +import { cloneDeep } from 'lodash'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import Icon from '~/vue_shared/components/icon.vue'; import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { cloneDeep } from 'lodash'; const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js index cbe478bfa1f..6f184e45600 100644 --- a/spec/frontend/releases/components/release_block_metadata_spec.js +++ b/spec/frontend/releases/components/release_block_metadata_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { cloneDeep } from 'lodash'; import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue'; import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { cloneDeep } from 'lodash'; const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js new file mode 100644 index 00000000000..0a04f68bd67 --- /dev/null +++ b/spec/frontend/releases/components/tag_field_exsting_spec.js @@ -0,0 +1,78 @@ +import { GlFormInput } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; +import createStore from '~/releases/stores'; +import createDetailModule from '~/releases/stores/modules/detail'; + +const TEST_TAG_NAME = 'test-tag-name'; +const TEST_DOCS_PATH = '/help/test/docs/path'; + +describe('releases/components/tag_field_existing', () => { + let store; + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(TagFieldExisting, { + store, + }); + }; + + const findInput = () => wrapper.find(GlFormInput); + const findHelp = () => wrapper.find('[data-testid="tag-name-help"]'); + const findHelpLink = () => { + const link = findHelp().find('a'); + + return { + text: link.text(), + href: link.attributes('href'), + target: link.attributes('target'), + }; + }; + + beforeEach(() => { + store = createStore({ + modules: { + detail: createDetailModule({ + updateReleaseApiDocsPath: TEST_DOCS_PATH, + tagName: TEST_TAG_NAME, + }), + }, + }); + + store.state.detail.release = { + tagName: TEST_TAG_NAME, + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('default', () => { + it('shows the tag name', () => { + createComponent(); + + expect(findInput().attributes()).toMatchObject({ + disabled: '', + value: TEST_TAG_NAME, + }); + }); + + it('shows help', () => { + createComponent(mount); + + expect(findHelp().text()).toMatchInterpolatedText( + 'Changing a Release tag is only supported via Releases API. More information', + ); + + const helpLink = findHelpLink(); + + expect(helpLink).toEqual({ + text: 'More information', + href: TEST_DOCS_PATH, + target: '_blank', + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js new file mode 100644 index 00000000000..b6ebc496f33 --- /dev/null +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -0,0 +1,144 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import TagFieldNew from '~/releases/components/tag_field_new.vue'; +import createStore from '~/releases/stores'; +import createDetailModule from '~/releases/stores/modules/detail'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +const TEST_TAG_NAME = 'test-tag-name'; +const TEST_PROJECT_ID = '1234'; +const TEST_CREATE_FROM = 'test-create-from'; + +describe('releases/components/tag_field_new', () => { + let store; + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(TagFieldNew, { + store, + stubs: { + RefSelector: true, + }, + }); + }; + + beforeEach(() => { + store = createStore({ + modules: { + detail: createDetailModule({ + projectId: TEST_PROJECT_ID, + }), + }, + }); + + store.state.detail.createFrom = TEST_CREATE_FROM; + + store.state.detail.release = { + tagName: TEST_TAG_NAME, + assets: { + links: [], + }, + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); + const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput); + const findTagNameInput = () => findTagNameFormGroup().find('input'); + + const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); + const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector); + + describe('"Tag name" field', () => { + describe('rendering and behavior', () => { + beforeEach(() => createComponent()); + + it('renders a label', () => { + expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); + }); + + describe('when the user updates the field', () => { + it("updates the store's release.tagName property", () => { + const updatedTagName = 'updated-tag-name'; + findTagNameGlInput().vm.$emit('input', updatedTagName); + + return wrapper.vm.$nextTick().then(() => { + expect(store.state.detail.release.tagName).toBe(updatedTagName); + }); + }); + }); + }); + + describe('validation', () => { + beforeEach(() => { + createComponent(mount); + }); + + /** + * Utility function to test the visibility of the validation message + * @param {'shown' | 'hidden'} state The expected state of the validation message. + * Should be passed either 'shown' or 'hidden' + */ + const expectValidationMessageToBe = state => { + return wrapper.vm.$nextTick().then(() => { + expect(findTagNameFormGroup().element).toHaveClass( + state === 'shown' ? 'is-invalid' : 'is-valid', + ); + expect(findTagNameFormGroup().element).not.toHaveClass( + state === 'shown' ? 'is-valid' : 'is-invalid', + ); + }); + }; + + describe('when the user has not yet interacted with the component', () => { + it('does not display a validation error', () => { + findTagNameInput().setValue(''); + + return expectValidationMessageToBe('hidden'); + }); + }); + + describe('when the user has interacted with the component and the value is not empty', () => { + it('does not display validation error', () => { + findTagNameInput().trigger('blur'); + + return expectValidationMessageToBe('hidden'); + }); + }); + + describe('when the user has interacted with the component and the value is empty', () => { + it('displays a validation error', () => { + const tagNameInput = findTagNameInput(); + + tagNameInput.setValue(''); + tagNameInput.trigger('blur'); + + return expectValidationMessageToBe('shown'); + }); + }); + }); + }); + + describe('"Create from" field', () => { + beforeEach(() => createComponent()); + + it('renders a label', () => { + expect(findCreateFromFormGroup().attributes().label).toBe('Create from'); + }); + + describe('when the user selects a git ref', () => { + it("updates the store's createFrom property", () => { + const updatedCreateFrom = 'update-create-from'; + findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); + + return wrapper.vm.$nextTick().then(() => { + expect(store.state.detail.createFrom).toBe(updatedCreateFrom); + }); + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js new file mode 100644 index 00000000000..c7909a2369b --- /dev/null +++ b/spec/frontend/releases/components/tag_field_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import TagField from '~/releases/components/tag_field.vue'; +import TagFieldNew from '~/releases/components/tag_field_new.vue'; +import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; +import createStore from '~/releases/stores'; +import createDetailModule from '~/releases/stores/modules/detail'; + +describe('releases/components/tag_field', () => { + let store; + let wrapper; + + const createComponent = ({ tagName }) => { + store = createStore({ + modules: { + detail: createDetailModule({}), + }, + }); + + store.state.detail.tagName = tagName; + + wrapper = shallowMount(TagField, { store }); + }; + + const findTagFieldNew = () => wrapper.find(TagFieldNew); + const findTagFieldExisting = () => wrapper.find(TagFieldExisting); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when an existing release is being edited', () => { + beforeEach(() => { + createComponent({ tagName: 'v1.0' }); + }); + + it('renders the TagFieldExisting component', () => { + expect(findTagFieldExisting().exists()).toBe(true); + }); + + it('does not render the TagFieldNew component', () => { + expect(findTagFieldNew().exists()).toBe(false); + }); + }); + + describe('when a new release is being created', () => { + beforeEach(() => { + createComponent({ tagName: null }); + }); + + it('renders the TagFieldNew component', () => { + expect(findTagFieldNew().exists()).toBe(true); + }); + + it('does not render the TagFieldExisting component', () => { + expect(findTagFieldExisting().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 345be2acc71..1b2a705e8f4 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,18 +1,20 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { cloneDeep, merge } from 'lodash'; +import { cloneDeep } from 'lodash'; import * as actions from '~/releases/stores/modules/detail/actions'; import * as types from '~/releases/stores/modules/detail/mutation_types'; import { release as originalRelease } from '../../../mock_data'; import createState from '~/releases/stores/modules/detail/state'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import api from '~/api'; +import httpStatus from '~/lib/utils/http_status'; import { ASSET_LINK_TYPE } from '~/releases/constants'; +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), @@ -25,15 +27,26 @@ describe('Release detail actions', () => { let mock; let error; + const setupState = (updates = {}) => { + const getters = { + isExistingRelease: true, + }; + + state = { + ...createState({ + projectId: '18', + tagName: release.tag_name, + releasesPagePath: 'path/to/releases/page', + markdownDocsPath: 'path/to/markdown/docs', + markdownPreviewPath: 'path/to/markdown/preview', + updateReleaseApiDocsPath: 'path/to/api/docs', + }), + ...getters, + ...updates, + }; + }; + beforeEach(() => { - state = createState({ - projectId: '18', - tagName: 'v1.3', - releasesPagePath: 'path/to/releases/page', - markdownDocsPath: 'path/to/markdown/docs', - markdownPreviewPath: 'path/to/markdown/preview', - updateReleaseApiDocsPath: 'path/to/api/docs', - }); release = cloneDeep(originalRelease); mock = new MockAdapter(axios); gon.api_version = 'v4'; @@ -45,284 +58,424 @@ describe('Release detail actions', () => { mock.restore(); }); - describe('requestRelease', () => { - it(`commits ${types.REQUEST_RELEASE}`, () => - testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }])); - }); + describe('when creating a new release', () => { + beforeEach(() => { + setupState({ isExistingRelease: false }); + }); - describe('receiveReleaseSuccess', () => { - it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveReleaseSuccess, release, state, [ - { type: types.RECEIVE_RELEASE_SUCCESS, payload: release }, - ])); + describe('initializeRelease', () => { + it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => { + testAction(actions.initializeRelease, undefined, state, [ + { type: types.INITIALIZE_EMPTY_RELEASE }, + ]); + }); + }); + + describe('saveRelease', () => { + it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => { + testAction( + actions.saveRelease, + undefined, + state, + [{ type: types.REQUEST_SAVE_RELEASE }], + [{ type: 'createRelease' }], + ); + }); + }); }); - describe('receiveReleaseError', () => { - it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () => - testAction(actions.receiveReleaseError, error, state, [ - { type: types.RECEIVE_RELEASE_ERROR, payload: error }, - ])); + describe('when editing an existing release', () => { + beforeEach(setupState); - it('shows a flash with an error message', () => { - actions.receiveReleaseError({ commit: jest.fn() }, error); + describe('initializeRelease', () => { + it('dispatches "fetchRelease"', () => { + testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]); + }); + }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while getting the release details', - ); + describe('saveRelease', () => { + it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => { + testAction( + actions.saveRelease, + undefined, + state, + [{ type: types.REQUEST_SAVE_RELEASE }], + [{ type: 'updateRelease' }], + ); + }); }); }); - describe('fetchRelease', () => { - let getReleaseUrl; + describe('actions that behave the same whether creating a new release or editing an existing release', () => { + beforeEach(setupState); - beforeEach(() => { - state.projectId = '18'; - state.tagName = 'v1.3'; - getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`; - }); + describe('fetchRelease', () => { + let getReleaseUrl; + + beforeEach(() => { + getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`; + }); + + describe('when the network request to the Release API is successful', () => { + beforeEach(() => { + mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release); + }); + + it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => { + return testAction(actions.fetchRelease, undefined, state, [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_SUCCESS, + payload: apiJsonToRelease(release, { deep: true }), + }, + ]); + }); + }); - it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => { - mock.onGet(getReleaseUrl).replyOnce(200, release); - - return testAction( - actions.fetchRelease, - undefined, - state, - [], - [ - { type: 'requestRelease' }, - { - type: 'receiveReleaseSuccess', - payload: convertObjectPropsToCamelCase(release, { deep: true }), - }, - ], - ); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => { + return testAction(actions.fetchRelease, undefined, state, [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ]); + }); + + it(`shows a flash message`, () => { + return actions.fetchRelease({ commit: jest.fn(), state }).then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while getting the release details', + ); + }); + }); + }); }); - it(`dispatches requestRelease and receiveReleaseError with an error object`, () => { - mock.onGet(getReleaseUrl).replyOnce(500); + describe('updateReleaseTagName', () => { + it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => { + const newTag = 'updated-tag-name'; + return testAction(actions.updateReleaseTagName, newTag, state, [ + { type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag }, + ]); + }); + }); - return testAction( - actions.fetchRelease, - undefined, - state, - [], - [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }], - ); + describe('updateCreateFrom', () => { + it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => { + const newRef = 'my-feature-branch'; + return testAction(actions.updateCreateFrom, newRef, state, [ + { type: types.UPDATE_CREATE_FROM, payload: newRef }, + ]); + }); }); - }); - describe('updateReleaseTitle', () => { - it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { - const newTitle = 'The new release title'; - return testAction(actions.updateReleaseTitle, newTitle, state, [ - { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, - ]); + describe('updateReleaseTitle', () => { + it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { + const newTitle = 'The new release title'; + return testAction(actions.updateReleaseTitle, newTitle, state, [ + { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, + ]); + }); }); - }); - describe('updateReleaseNotes', () => { - it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { - const newReleaseNotes = 'The new release notes'; - return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [ - { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, - ]); + describe('updateReleaseNotes', () => { + it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { + const newReleaseNotes = 'The new release notes'; + return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [ + { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, + ]); + }); }); - }); - describe('updateAssetLinkUrl', () => { - it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => { - const params = { - linkIdToUpdate: 2, - newUrl: 'https://example.com/updated', - }; + describe('updateReleaseMilestones', () => { + it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { + const newReleaseMilestones = ['v0.0', 'v0.1']; + return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ + { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, + ]); + }); + }); - return testAction(actions.updateAssetLinkUrl, params, state, [ - { type: types.UPDATE_ASSET_LINK_URL, payload: params }, - ]); + describe('addEmptyAssetLink', () => { + it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => { + return testAction(actions.addEmptyAssetLink, undefined, state, [ + { type: types.ADD_EMPTY_ASSET_LINK }, + ]); + }); }); - }); - describe('updateAssetLinkName', () => { - it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => { - const params = { - linkIdToUpdate: 2, - newName: 'Updated link name', - }; + describe('updateAssetLinkUrl', () => { + it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => { + const params = { + linkIdToUpdate: 2, + newUrl: 'https://example.com/updated', + }; - return testAction(actions.updateAssetLinkName, params, state, [ - { type: types.UPDATE_ASSET_LINK_NAME, payload: params }, - ]); + return testAction(actions.updateAssetLinkUrl, params, state, [ + { type: types.UPDATE_ASSET_LINK_URL, payload: params }, + ]); + }); }); - }); - describe('updateAssetLinkType', () => { - it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => { - const params = { - linkIdToUpdate: 2, - newType: ASSET_LINK_TYPE.RUNBOOK, - }; + describe('updateAssetLinkName', () => { + it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => { + const params = { + linkIdToUpdate: 2, + newName: 'Updated link name', + }; - return testAction(actions.updateAssetLinkType, params, state, [ - { type: types.UPDATE_ASSET_LINK_TYPE, payload: params }, - ]); + return testAction(actions.updateAssetLinkName, params, state, [ + { type: types.UPDATE_ASSET_LINK_NAME, payload: params }, + ]); + }); }); - }); - describe('removeAssetLink', () => { - it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => { - const idToRemove = 2; - return testAction(actions.removeAssetLink, idToRemove, state, [ - { type: types.REMOVE_ASSET_LINK, payload: idToRemove }, - ]); + describe('updateAssetLinkType', () => { + it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => { + const params = { + linkIdToUpdate: 2, + newType: ASSET_LINK_TYPE.RUNBOOK, + }; + + return testAction(actions.updateAssetLinkType, params, state, [ + { type: types.UPDATE_ASSET_LINK_TYPE, payload: params }, + ]); + }); }); - }); - describe('updateReleaseMilestones', () => { - it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { - const newReleaseMilestones = ['v0.0', 'v0.1']; - return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ - { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, - ]); + describe('removeAssetLink', () => { + it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => { + const idToRemove = 2; + return testAction(actions.removeAssetLink, idToRemove, state, [ + { type: types.REMOVE_ASSET_LINK, payload: idToRemove }, + ]); + }); }); - }); - describe('requestUpdateRelease', () => { - it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => - testAction(actions.requestUpdateRelease, undefined, state, [ - { type: types.REQUEST_UPDATE_RELEASE }, - ])); - }); + describe('receiveSaveReleaseSuccess', () => { + it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => + testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [ + { type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, + ])); - describe('receiveUpdateReleaseSuccess', () => { - it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [ - { type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }, - ])); + describe('when the releaseShowPage feature flag is enabled', () => { + beforeEach(() => { + const rootState = { featureFlags: { releaseShowPage: true } }; + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); + }); - it('redirects to the releases page if releaseShowPage feature flag is enabled', () => { - const rootState = { featureFlags: { releaseShowPage: true } }; - const updatedState = merge({}, state, { - releasesPagePath: 'path/to/releases/page', - release: { - _links: { - self: 'path/to/self', - }, - }, + it("redirects to the release's dedicated page", () => { + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(release._links.self); + }); }); - actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState }); + describe('when the releaseShowPage feature flag is disabled', () => { + beforeEach(() => { + const rootState = { featureFlags: { releaseShowPage: false } }; + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); + }); - expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self); + it("redirects to the project's main Releases page", () => { + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath); + }); + }); }); - describe('when the releaseShowPage feature flag is disabled', () => {}); - }); - - describe('receiveUpdateReleaseError', () => { - it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => - testAction(actions.receiveUpdateReleaseError, error, state, [ - { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error }, - ])); + describe('createRelease', () => { + let createReleaseUrl; + let releaseLinksToCreate; - it('shows a flash with an error message', () => { - actions.receiveUpdateReleaseError({ commit: jest.fn() }, error); + beforeEach(() => { + const camelCasedRelease = convertObjectPropsToCamelCase(release); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details', - ); - }); - }); + releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1); - describe('updateRelease', () => { - let getters; - let dispatch; - let callOrder; + setupState({ + release: camelCasedRelease, + releaseLinksToCreate, + }); - beforeEach(() => { - state.release = convertObjectPropsToCamelCase(release); - state.projectId = '18'; - state.tagName = state.release.tagName; + createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`; + }); - getters = { - releaseLinksToDelete: [{ id: '1' }, { id: '2' }], - releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }], - }; + describe('when the network request to the Release API is successful', () => { + beforeEach(() => { + const expectedRelease = releaseToApiJson({ + ...state.release, + assets: { + links: releaseLinksToCreate, + }, + }); - dispatch = jest.fn(); + mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release); + }); - callOrder = []; - jest.spyOn(api, 'updateRelease').mockImplementation(() => { - callOrder.push('updateRelease'); - return Promise.resolve(); - }); - jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { - callOrder.push('deleteReleaseLink'); - return Promise.resolve(); - }); - jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { - callOrder.push('createReleaseLink'); - return Promise.resolve(); + it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => { + return testAction( + actions.createRelease, + undefined, + state, + [], + [ + { + type: 'receiveSaveReleaseSuccess', + payload: apiJsonToRelease(release, { deep: true }), + }, + ], + ); + }); }); - }); - it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => { - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(dispatch.mock.calls).toEqual([ - ['requestUpdateRelease'], - ['receiveUpdateReleaseSuccess'], - ]); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { + return testAction(actions.createRelease, undefined, state, [ + { + type: types.RECEIVE_SAVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ]); + }); + + it(`shows a flash message`, () => { + return actions + .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) + .then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while creating a new release', + ); + }); + }); }); }); - it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { - jest.spyOn(api, 'updateRelease').mockRejectedValue(error); + describe('updateRelease', () => { + let getters; + let dispatch; + let commit; + let callOrder; + + beforeEach(() => { + getters = { + releaseLinksToDelete: [{ id: '1' }, { id: '2' }], + releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }], + }; + + setupState({ + release: convertObjectPropsToCamelCase(release), + ...getters, + }); - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(dispatch.mock.calls).toEqual([ - ['requestUpdateRelease'], - ['receiveUpdateReleaseError', error], - ]); + dispatch = jest.fn(); + commit = jest.fn(); + + callOrder = []; + jest.spyOn(api, 'updateRelease').mockImplementation(() => { + callOrder.push('updateRelease'); + return Promise.resolve({ data: release }); + }); + jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { + callOrder.push('deleteReleaseLink'); + return Promise.resolve(); + }); + jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { + callOrder.push('createReleaseLink'); + return Promise.resolve(); + }); }); - }); - it('updates the Release, then deletes all existing links, and then recreates new links', () => { - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(callOrder).toEqual([ - 'updateRelease', - 'deleteReleaseLink', - 'deleteReleaseLink', - 'createReleaseLink', - 'createReleaseLink', - ]); + describe('when the network request to the Release API is successful', () => { + it('dispatches receiveSaveReleaseSuccess', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(dispatch.mock.calls).toEqual([ + ['receiveSaveReleaseSuccess', apiJsonToRelease(release)], + ]); + }); + }); - expect(api.updateRelease.mock.calls).toEqual([ - [ - state.projectId, - state.tagName, - { - name: state.release.name, - description: state.release.description, - milestones: state.release.milestones.map(milestone => milestone.title), - }, - ], - ]); + it('updates the Release, then deletes all existing links, and then recreates new links', () => { + return actions.updateRelease({ dispatch, state, getters }).then(() => { + expect(callOrder).toEqual([ + 'updateRelease', + 'deleteReleaseLink', + 'deleteReleaseLink', + 'createReleaseLink', + 'createReleaseLink', + ]); + + expect(api.updateRelease.mock.calls).toEqual([ + [ + state.projectId, + state.tagName, + releaseToApiJson({ + ...state.release, + assets: { + links: getters.releaseLinksToCreate, + }, + }), + ], + ]); + + expect(api.deleteReleaseLink).toHaveBeenCalledTimes( + getters.releaseLinksToDelete.length, + ); + getters.releaseLinksToDelete.forEach(link => { + expect(api.deleteReleaseLink).toHaveBeenCalledWith( + state.projectId, + state.tagName, + link.id, + ); + }); + + expect(api.createReleaseLink).toHaveBeenCalledTimes( + getters.releaseLinksToCreate.length, + ); + getters.releaseLinksToCreate.forEach(link => { + expect(api.createReleaseLink).toHaveBeenCalledWith( + state.projectId, + state.tagName, + link, + ); + }); + }); + }); + }); - expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length); - getters.releaseLinksToDelete.forEach(link => { - expect(api.deleteReleaseLink).toHaveBeenCalledWith( - state.projectId, - state.tagName, - link.id, - ); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + jest.spyOn(api, 'updateRelease').mockRejectedValue(error); + }); + + it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]); + }); }); - expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length); - getters.releaseLinksToCreate.forEach(link => { - expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link); + it('shows a flash message', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while saving the release details', + ); + }); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 8945ad97c93..2d9f35428f2 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -1,6 +1,20 @@ import * as getters from '~/releases/stores/modules/detail/getters'; describe('Release detail getters', () => { + describe('isExistingRelease', () => { + it('returns true if the release is an existing release that already exists in the database', () => { + const state = { tagName: 'test-tag-name' }; + + expect(getters.isExistingRelease(state)).toBe(true); + }); + + it('returns false if the release is a new release that has not yet been saved to the database', () => { + const state = { tagName: null }; + + expect(getters.isExistingRelease(state)).toBe(false); + }); + }); + describe('releaseLinksToCreate', () => { it("returns an empty array if state.release doesn't exist", () => { const state = {}; @@ -62,6 +76,7 @@ describe('Release detail getters', () => { it('returns no validation errors', () => { const state = { release: { + tagName: 'test-tag-name', assets: { links: [ { id: 1, url: 'https://example.com/valid', name: 'Link 1' }, @@ -96,6 +111,9 @@ describe('Release detail getters', () => { beforeEach(() => { const state = { release: { + // empty tag name + tagName: '', + assets: { links: [ // Duplicate URLs @@ -124,7 +142,15 @@ describe('Release detail getters', () => { actualErrors = getters.validationErrors(state); }); - it('returns a validation errors if links share a URL', () => { + it('returns a validation error if the tag name is empty', () => { + const expectedErrors = { + isTagNameEmpty: true, + }; + + expect(actualErrors).toMatchObject(expectedErrors); + }); + + it('returns a validation error if links share a URL', () => { const expectedErrors = { assets: { links: { @@ -182,32 +208,53 @@ describe('Release detail getters', () => { // the value of state is not actually used by this getter const state = {}; - it('returns true when the form is valid', () => { - const mockGetters = { - validationErrors: { - assets: { - links: { - 1: {}, + describe('when the form is valid', () => { + it('returns true', () => { + const mockGetters = { + validationErrors: { + assets: { + links: { + 1: {}, + }, }, }, - }, - }; + }; - expect(getters.isValid(state, mockGetters)).toBe(true); + expect(getters.isValid(state, mockGetters)).toBe(true); + }); }); - it('returns false when the form is invalid', () => { - const mockGetters = { - validationErrors: { - assets: { - links: { - 1: { isNameEmpty: true }, + describe('when an asset link contains a validation error', () => { + it('returns false', () => { + const mockGetters = { + validationErrors: { + assets: { + links: { + 1: { isNameEmpty: true }, + }, }, }, - }, - }; + }; - expect(getters.isValid(state, mockGetters)).toBe(false); + expect(getters.isValid(state, mockGetters)).toBe(false); + }); + }); + + describe('when the tag name is empty', () => { + it('returns false', () => { + const mockGetters = { + validationErrors: { + isTagNameEmpty: true, + assets: { + links: { + 1: {}, + }, + }, + }, + }; + + expect(getters.isValid(state, mockGetters)).toBe(false); + }); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index a34c1be64d9..cd7c6b7d275 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -21,6 +21,22 @@ describe('Release detail mutations', () => { release = convertObjectPropsToCamelCase(originalRelease); }); + describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => { + it('set state.release to an empty release object', () => { + mutations[types.INITIALIZE_EMPTY_RELEASE](state); + + expect(state.release).toEqual({ + tagName: null, + name: '', + description: '', + milestones: [], + assets: { + links: [], + }, + }); + }); + }); + describe(`${types.REQUEST_RELEASE}`, () => { it('set state.isFetchingRelease to true', () => { mutations[types.REQUEST_RELEASE](state); @@ -56,6 +72,26 @@ describe('Release detail mutations', () => { }); }); + describe(`${types.UPDATE_RELEASE_TAG_NAME}`, () => { + it("updates the release's tag name", () => { + state.release = release; + const newTag = 'updated-tag-name'; + mutations[types.UPDATE_RELEASE_TAG_NAME](state, newTag); + + expect(state.release.tagName).toBe(newTag); + }); + }); + + describe(`${types.UPDATE_CREATE_FROM}`, () => { + it('updates the ref that the ref will be created from', () => { + state.createFrom = 'main'; + const newRef = 'my-feature-branch'; + mutations[types.UPDATE_CREATE_FROM](state, newRef); + + expect(state.createFrom).toBe(newRef); + }); + }); + describe(`${types.UPDATE_RELEASE_TITLE}`, () => { it("updates the release's title", () => { state.release = release; @@ -76,17 +112,17 @@ describe('Release detail mutations', () => { }); }); - describe(`${types.REQUEST_UPDATE_RELEASE}`, () => { + describe(`${types.REQUEST_SAVE_RELEASE}`, () => { it('set state.isUpdatingRelease to true', () => { - mutations[types.REQUEST_UPDATE_RELEASE](state); + mutations[types.REQUEST_SAVE_RELEASE](state); expect(state.isUpdatingRelease).toBe(true); }); }); - describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => { + describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => { it('handles a successful response from the server', () => { - mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release); + mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release); expect(state.updateError).toBeUndefined(); @@ -94,10 +130,10 @@ describe('Release detail mutations', () => { }); }); - describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => { + describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => { it('handles an unsuccessful response from the server', () => { const error = { message: 'An error occurred!' }; - mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error); + mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error); expect(state.isUpdatingRelease).toBe(false); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js new file mode 100644 index 00000000000..90aa9c4c7d8 --- /dev/null +++ b/spec/frontend/releases/util_spec.js @@ -0,0 +1,103 @@ +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; + +describe('releases/util.js', () => { + describe('releaseToApiJson', () => { + it('converts a release JavaScript object into JSON that the Release API can accept', () => { + const release = { + tagName: 'tag-name', + name: 'Release name', + description: 'Release description', + milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }], + assets: { + links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }], + }, + }; + + const expectedJson = { + tag_name: 'tag-name', + ref: null, + name: 'Release name', + description: 'Release description', + milestones: ['13.2', '13.3'], + assets: { + links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }], + }, + }; + + expect(releaseToApiJson(release)).toEqual(expectedJson); + }); + + describe('when createFrom is provided', () => { + it('adds the provided createFrom ref to the JSON as a "ref" property', () => { + const createFrom = 'main'; + + const release = {}; + + const expectedJson = { + ref: createFrom, + }; + + expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson); + }); + }); + + describe('release.name', () => { + it.each` + input | output + ${null} | ${null} + ${''} | ${null} + ${' \t\n\r\n'} | ${null} + ${' Release name '} | ${'Release name'} + `('converts a name like `$input` to `$output`', ({ input, output }) => { + const release = { name: input }; + + const expectedJson = { + name: output, + }; + + expect(releaseToApiJson(release)).toMatchObject(expectedJson); + }); + }); + + describe('when release.milestones is falsy', () => { + it('includes a "milestone" property in the returned result as an empty array', () => { + const release = {}; + + const expectedJson = { + milestones: [], + }; + + expect(releaseToApiJson(release)).toMatchObject(expectedJson); + }); + }); + }); + + describe('apiJsonToRelease', () => { + it('converts JSON received from the Release API into an object usable by the Vue application', () => { + const json = { + tag_name: 'tag-name', + assets: { + links: [ + { + link_type: 'other', + }, + ], + }, + }; + + const expectedRelease = { + tagName: 'tag-name', + assets: { + links: [ + { + linkType: 'other', + }, + ], + }, + milestones: [], + }; + + expect(apiJsonToRelease(json)).toEqual(expectedRelease); + }); + }); +}); |