summaryrefslogtreecommitdiff
path: root/spec/frontend/releases
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/releases')
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js (renamed from spec/frontend/releases/components/app_edit_spec.js)103
-rw-r--r--spec/frontend/releases/components/app_index_spec.js2
-rw-r--r--spec/frontend/releases/components/app_new_spec.js26
-rw-r--r--spec/frontend/releases/components/app_show_spec.js2
-rw-r--r--spec/frontend/releases/components/asset_links_form_spec.js143
-rw-r--r--spec/frontend/releases/components/release_block_assets_spec.js4
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js2
-rw-r--r--spec/frontend/releases/components/release_block_metadata_spec.js2
-rw-r--r--spec/frontend/releases/components/tag_field_exsting_spec.js78
-rw-r--r--spec/frontend/releases/components/tag_field_new_spec.js144
-rw-r--r--spec/frontend/releases/components/tag_field_spec.js59
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js615
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js85
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js48
-rw-r--r--spec/frontend/releases/util_spec.js103
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);
+ });
+ });
+});