diff options
Diffstat (limited to 'spec/frontend/packages_and_registries')
19 files changed, 1663 insertions, 149 deletions
diff --git a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap new file mode 100644 index 00000000000..f2087733d2b --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`settings_titles renders properly 1`] = ` +<div> + <h5 + class="gl-border-b-solid gl-border-b-1 gl-border-gray-200" + > + + foo + + </h5> + + <p> + bar + </p> + +</div> +`; diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js new file mode 100644 index 00000000000..0bbb1ce3436 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js @@ -0,0 +1,146 @@ +import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; + +import { + DUPLICATES_TOGGLE_LABEL, + DUPLICATES_ALLOWED_ENABLED, + DUPLICATES_ALLOWED_DISABLED, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, +} from '~/packages_and_registries/settings/group/constants'; + +describe('Duplicates Settings', () => { + let wrapper; + + const defaultProps = { + duplicatesAllowed: false, + duplicateExceptionRegex: 'foo', + modelNames: { + allowed: 'allowedModel', + exception: 'exceptionModel', + }, + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findToggle = () => wrapper.findComponent(GlToggle); + const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"'); + + const findInputGroup = () => wrapper.findComponent(GlFormGroup); + const findInput = () => wrapper.findComponent(GlFormInput); + + it('has a toggle', () => { + mountComponent(); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props()).toMatchObject({ + label: DUPLICATES_TOGGLE_LABEL, + value: defaultProps.duplicatesAllowed, + }); + }); + + it('toggle emits an update event', () => { + mountComponent(); + + findToggle().vm.$emit('change', false); + + expect(wrapper.emitted('update')).toStrictEqual([ + [{ [defaultProps.modelNames.allowed]: false }], + ]); + }); + + describe('when the duplicates are disabled', () => { + it('the toggle has the disabled message', () => { + mountComponent(); + + expect(findToggleLabel().exists()).toBe(true); + expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_DISABLED); + }); + + it('shows a form group with an input field', () => { + mountComponent(); + + expect(findInputGroup().exists()).toBe(true); + + expect(findInputGroup().attributes()).toMatchObject({ + 'label-for': 'maven-duplicated-settings-regex-input', + label: DUPLICATES_SETTING_EXCEPTION_TITLE, + description: DUPLICATES_SETTINGS_EXCEPTION_LEGEND, + }); + }); + + it('shows an input field', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + + expect(findInput().attributes()).toMatchObject({ + id: 'maven-duplicated-settings-regex-input', + value: defaultProps.duplicateExceptionRegex, + }); + }); + + it('input change event emits an update event', () => { + mountComponent(); + + findInput().vm.$emit('change', 'bar'); + + expect(wrapper.emitted('update')).toStrictEqual([ + [{ [defaultProps.modelNames.exception]: 'bar' }], + ]); + }); + + describe('valid state', () => { + it('form group has correct props', () => { + mountComponent(); + + expect(findInputGroup().attributes()).toMatchObject({ + state: 'true', + 'invalid-feedback': '', + }); + }); + }); + + describe('invalid state', () => { + it('form group has correct props', () => { + const propsWithError = { + ...defaultProps, + duplicateExceptionRegexError: 'some error string', + }; + + mountComponent(propsWithError); + + expect(findInputGroup().attributes()).toMatchObject({ + 'invalid-feedback': propsWithError.duplicateExceptionRegexError, + }); + }); + }); + }); + + describe('when the duplicates are enabled', () => { + it('has the correct toggle label', () => { + mountComponent({ ...defaultProps, duplicatesAllowed: true }); + + expect(findToggleLabel().exists()).toBe(true); + expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_ENABLED); + }); + + it('hides the form input group', () => { + mountComponent({ ...defaultProps, duplicatesAllowed: true }); + + expect(findInputGroup().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js new file mode 100644 index 00000000000..4eafeedd55e --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/generic_settings_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; + +describe('generic_settings', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(GenericSettings, { + scopedSlots: { + default: '<div data-testid="default-slot">{{props.modelNames}}</div>', + }, + }); + }; + + const findSettingsTitle = () => wrapper.findComponent(SettingsTitles); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('title component', () => { + it('has a title component', () => { + mountComponent(); + + expect(findSettingsTitle().exists()).toBe(true); + }); + + it('passes the correct props', () => { + mountComponent(); + + expect(findSettingsTitle().props()).toMatchObject({ + title: 'Generic', + subTitle: 'Settings for Generic packages', + }); + }); + }); + + describe('default slot', () => { + it('accept a default slots', () => { + mountComponent(); + + expect(findDefaultSlot().exists()).toBe(true); + }); + + it('binds model names', () => { + mountComponent(); + + expect(findDefaultSlot().text()).toContain('genericDuplicatesAllowed'); + expect(findDefaultSlot().text()).toContain('genericDuplicateExceptionRegex'); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index be0d7114e6e..14ee3f3e3b8 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -3,6 +3,8 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; import { @@ -63,6 +65,8 @@ describe('Group Settings App', () => { stubs: { GlSprintf, SettingsBlock, + MavenSettings, + GenericSettings, }, mocks: { $toast: { @@ -78,14 +82,17 @@ describe('Group Settings App', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findSettingsBlock = () => wrapper.find(SettingsBlock); + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); const findDescription = () => wrapper.find('[data-testid="description"'); - const findLink = () => wrapper.find(GlLink); - const findMavenSettings = () => wrapper.find(MavenSettings); - const findAlert = () => wrapper.find(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + const findAlert = () => wrapper.findComponent(GlAlert); + const findMavenSettings = () => wrapper.findComponent(MavenSettings); + const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); + const findGenericSettings = () => wrapper.findComponent(GenericSettings); + const findGenericDuplicatedSettings = () => + findGenericSettings().findComponent(DuplicatesSettings); const waitForApolloQueryAndRender = async () => { await waitForPromises(); @@ -93,7 +100,7 @@ describe('Group Settings App', () => { }; const emitSettingsUpdate = (override) => { - findMavenSettings().vm.$emit('update', { + findMavenDuplicatedSettings().vm.$emit('update', { mavenDuplicateExceptionRegex: ')', ...override, }); @@ -152,7 +159,7 @@ describe('Group Settings App', () => { it('assigns duplication allowness and exception props', async () => { mountComponent(); - expect(findMavenSettings().props('loading')).toBe(true); + expect(findMavenDuplicatedSettings().props('loading')).toBe(true); await waitForApolloQueryAndRender(); @@ -161,10 +168,10 @@ describe('Group Settings App', () => { mavenDuplicateExceptionRegex, } = groupPackageSettingsMock.data.group.packageSettings; - expect(findMavenSettings().props()).toMatchObject({ - mavenDuplicatesAllowed, - mavenDuplicateExceptionRegex, - mavenDuplicateExceptionRegexError: '', + expect(findMavenDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: mavenDuplicatesAllowed, + duplicateExceptionRegex: mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: '', loading: false, }); }); @@ -183,6 +190,49 @@ describe('Group Settings App', () => { }); }); + describe('generic settings', () => { + it('exists', () => { + mountComponent(); + + expect(findGenericSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + expect(findGenericDuplicatedSettings().props('loading')).toBe(true); + + await waitForApolloQueryAndRender(); + + const { + genericDuplicatesAllowed, + genericDuplicateExceptionRegex, + } = groupPackageSettingsMock.data.group.packageSettings; + + expect(findGenericDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: genericDuplicatesAllowed, + duplicateExceptionRegex: genericDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', async () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + await waitForApolloQueryAndRender(); + + findMavenDuplicatedSettings().vm.$emit('update', { + genericDuplicateExceptionRegex: ')', + }); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + describe('settings update', () => { describe('success state', () => { it('shows a success alert', async () => { @@ -200,26 +250,26 @@ describe('Group Settings App', () => { }); it('has an optimistic response', async () => { - const mavenDuplicateExceptionRegex = 'latest[master]something'; + const mavenDuplicateExceptionRegex = 'latest[main]something'; mountComponent(); await waitForApolloQueryAndRender(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe(''); + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); emitSettingsUpdate({ mavenDuplicateExceptionRegex }); // wait for apollo to update the model with the optimistic response await wrapper.vm.$nextTick(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( mavenDuplicateExceptionRegex, ); // wait for the call to resolve await waitForPromises(); - expect(findMavenSettings().props('mavenDuplicateExceptionRegex')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( mavenDuplicateExceptionRegex, ); }); @@ -245,7 +295,7 @@ describe('Group Settings App', () => { await waitForApolloQueryAndRender(); // errors are bound to the component - expect(findMavenSettings().props('mavenDuplicateExceptionRegexError')).toBe( + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, ); @@ -258,7 +308,7 @@ describe('Group Settings App', () => { await wrapper.vm.$nextTick(); // errors are reset on mutation call - expect(findMavenSettings().props('mavenDuplicateExceptionRegexError')).toBe(''); + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); }); it.each` diff --git a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js index 859d3587223..22644b97b43 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/maven_settings_spec.js @@ -1,156 +1,54 @@ -import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; -import { - MAVEN_TITLE, - MAVEN_SETTINGS_SUBTITLE, - MAVEN_DUPLICATES_ALLOWED_DISABLED, - MAVEN_DUPLICATES_ALLOWED_ENABLED, - MAVEN_SETTING_EXCEPTION_TITLE, - MAVEN_SETTINGS_EXCEPTION_LEGEND, -} from '~/packages_and_registries/settings/group/constants'; - -describe('Maven Settings', () => { +describe('maven_settings', () => { let wrapper; - const defaultProps = { - mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'foo', - }; - - const mountComponent = (propsData = defaultProps) => { - wrapper = shallowMount(component, { - propsData, - stubs: { - GlSprintf, + const mountComponent = () => { + wrapper = shallowMount(MavenSettings, { + scopedSlots: { + default: '<div data-testid="default-slot">{{props.modelNames}}</div>', }, }); }; + const findSettingsTitle = () => wrapper.findComponent(SettingsTitles); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findTitle = () => wrapper.find('h5'); - const findSubTitle = () => wrapper.find('p'); - const findToggle = () => wrapper.find(GlToggle); - const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"'); - - const findInputGroup = () => wrapper.find(GlFormGroup); - const findInput = () => wrapper.find(GlFormInput); - - it('has a title', () => { - mountComponent(); - - expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe(MAVEN_TITLE); - }); - - it('has a subtitle', () => { - mountComponent(); - - expect(findSubTitle().exists()).toBe(true); - expect(findSubTitle().text()).toBe(MAVEN_SETTINGS_SUBTITLE); - }); - - it('has a toggle', () => { - mountComponent(); - - expect(findToggle().exists()).toBe(true); - expect(findToggle().props()).toMatchObject({ - label: component.i18n.MAVEN_TOGGLE_LABEL, - value: defaultProps.mavenDuplicatesAllowed, - }); - }); - - it('toggle emits an update event', () => { - mountComponent(); - - findToggle().vm.$emit('change', false); - - expect(wrapper.emitted('update')).toEqual([[{ mavenDuplicatesAllowed: false }]]); - }); - - describe('when the duplicates are disabled', () => { - it('the toggle has the disabled message', () => { + describe('title component', () => { + it('has a title component', () => { mountComponent(); - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(MAVEN_DUPLICATES_ALLOWED_DISABLED); + expect(findSettingsTitle().exists()).toBe(true); }); - it('shows a form group with an input field', () => { + it('passes the correct props', () => { mountComponent(); - expect(findInputGroup().exists()).toBe(true); - - expect(findInputGroup().attributes()).toMatchObject({ - 'label-for': 'maven-duplicated-settings-regex-input', - label: MAVEN_SETTING_EXCEPTION_TITLE, - description: MAVEN_SETTINGS_EXCEPTION_LEGEND, + expect(findSettingsTitle().props()).toMatchObject({ + title: 'Maven', + subTitle: 'Settings for Maven packages', }); }); + }); - it('shows an input field', () => { + describe('default slot', () => { + it('accept a default slots', () => { mountComponent(); - expect(findInput().exists()).toBe(true); - - expect(findInput().attributes()).toMatchObject({ - id: 'maven-duplicated-settings-regex-input', - value: defaultProps.mavenDuplicateExceptionRegex, - }); + expect(findDefaultSlot().exists()).toBe(true); }); - it('input change event emits an update event', () => { + it('binds model names', () => { mountComponent(); - findInput().vm.$emit('change', 'bar'); - - expect(wrapper.emitted('update')).toEqual([[{ mavenDuplicateExceptionRegex: 'bar' }]]); - }); - - describe('valid state', () => { - it('form group has correct props', () => { - mountComponent(); - - expect(findInputGroup().attributes()).toMatchObject({ - state: 'true', - 'invalid-feedback': '', - }); - }); - }); - - describe('invalid state', () => { - it('form group has correct props', () => { - const propsWithError = { - ...defaultProps, - mavenDuplicateExceptionRegexError: 'some error string', - }; - - mountComponent(propsWithError); - - expect(findInputGroup().attributes()).toMatchObject({ - 'invalid-feedback': propsWithError.mavenDuplicateExceptionRegexError, - }); - }); - }); - }); - - describe('when the duplicates are enabled', () => { - it('has the correct toggle label', () => { - mountComponent({ ...defaultProps, mavenDuplicatesAllowed: true }); - - expect(findToggleLabel().exists()).toBe(true); - expect(findToggleLabel().text()).toMatchInterpolatedText(MAVEN_DUPLICATES_ALLOWED_ENABLED); - }); - - it('hides the form input group', () => { - mountComponent({ ...defaultProps, mavenDuplicatesAllowed: true }); - - expect(findInputGroup().exists()).toBe(false); + expect(findDefaultSlot().text()).toContain('mavenDuplicatesAllowed'); + expect(findDefaultSlot().text()).toContain('mavenDuplicateExceptionRegex'); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js new file mode 100644 index 00000000000..a61edad8685 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; + +describe('settings_titles', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(SettingsTitles, { + propsData: { + title: 'foo', + subTitle: 'bar', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders properly', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js index e1a46f97318..03133bf1158 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js @@ -9,7 +9,7 @@ describe('Package and Registries settings group cache updates', () => { updateNamespacePackageSettings: { packageSettings: { mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'latest[master]something', + mavenDuplicateExceptionRegex: 'latest[main]something', }, }, }, diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index 777c0898de0..65119e288a1 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -4,6 +4,8 @@ export const groupPackageSettingsMock = { packageSettings: { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: '', }, }, }, @@ -14,7 +16,9 @@ export const groupPackageSettingsMutationMock = (override) => ({ updateNamespacePackageSettings: { packageSettings: { mavenDuplicatesAllowed: true, - mavenDuplicateExceptionRegex: 'latest[master]something', + mavenDuplicateExceptionRegex: 'latest[main]something', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: 'latest[main]somethingGeneric', }, errors: [], ...override, @@ -26,20 +30,20 @@ export const groupPackageSettingsMutationErrorMock = { errors: [ { message: - 'Variable $input of type UpdateNamespacePackageSettingsInput! was provided invalid value for mavenDuplicateExceptionRegex (latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]somethingj)))', + 'Variable $input of type UpdateNamespacePackageSettingsInput! was provided invalid value for mavenDuplicateExceptionRegex (latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]somethingj)))', locations: [{ line: 1, column: 41 }], extensions: { value: { namespacePath: 'gitlab-org', - mavenDuplicateExceptionRegex: 'latest[master]something))', + mavenDuplicateExceptionRegex: 'latest[main]something))', }, problems: [ { path: ['mavenDuplicateExceptionRegex'], explanation: - 'latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]something))', + 'latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]something))', message: - 'latest[master]somethingj)) is an invalid regexp: unexpected ): latest[master]something))', + 'latest[main]somethingj)) is an invalid regexp: unexpected ): latest[main]something))', }, ], }, diff --git a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..7062773b46b --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Utils formOptionsGenerator returns an object containing cadence 1`] = ` +Array [ + Object { + "default": true, + "key": "EVERY_DAY", + "label": "Every day", + }, + Object { + "default": false, + "key": "EVERY_WEEK", + "label": "Every week", + }, + Object { + "default": false, + "key": "EVERY_TWO_WEEKS", + "label": "Every two weeks", + }, + Object { + "default": false, + "key": "EVERY_MONTH", + "label": "Every month", + }, + Object { + "default": false, + "key": "EVERY_THREE_MONTHS", + "label": "Every three months", + }, +] +`; + +exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = ` +Array [ + Object { + "default": false, + "key": "ONE_TAG", + "label": "1 tag per image name", + "variable": 1, + }, + Object { + "default": false, + "key": "FIVE_TAGS", + "label": "5 tags per image name", + "variable": 5, + }, + Object { + "default": true, + "key": "TEN_TAGS", + "label": "10 tags per image name", + "variable": 10, + }, + Object { + "default": false, + "key": "TWENTY_FIVE_TAGS", + "label": "25 tags per image name", + "variable": 25, + }, + Object { + "default": false, + "key": "FIFTY_TAGS", + "label": "50 tags per image name", + "variable": 50, + }, + Object { + "default": false, + "key": "ONE_HUNDRED_TAGS", + "label": "100 tags per image name", + "variable": 100, + }, +] +`; + +exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = ` +Array [ + Object { + "default": false, + "key": "SEVEN_DAYS", + "label": "7 days", + "variable": 7, + }, + Object { + "default": false, + "key": "FOURTEEN_DAYS", + "label": "14 days", + "variable": 14, + }, + Object { + "default": false, + "key": "THIRTY_DAYS", + "label": "30 days", + "variable": 30, + }, + Object { + "default": true, + "key": "NINETY_DAYS", + "label": "90 days", + "variable": 90, + }, +] +`; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap new file mode 100644 index 00000000000..7a52b4a5d0f --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Settings Form Cadence matches snapshot 1`] = ` +<expiration-dropdown-stub + class="gl-mr-7 gl-mb-0!" + data-testid="cadence-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" + label="Run cleanup:" + name="cadence" + value="EVERY_DAY" +/> +`; + +exports[`Settings Form Enable matches snapshot 1`] = ` +<expiration-toggle-stub + class="gl-mb-0!" + data-testid="enable-toggle" + value="true" +/> +`; + +exports[`Settings Form Keep N matches snapshot 1`] = ` +<expiration-dropdown-stub + data-testid="keep-n-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" + label="Keep the most recent:" + name="keep-n" + value="TEN_TAGS" +/> +`; + +exports[`Settings Form Keep Regex matches snapshot 1`] = ` +<expiration-input-stub + data-testid="keep-regex-input" + description="Tags with names that match this regex pattern are kept. %{linkStart}View regex examples.%{linkEnd}" + error="" + label="Keep tags matching:" + name="keep-regex" + placeholder="" + value="sss" +/> +`; + +exports[`Settings Form OlderThan matches snapshot 1`] = ` +<expiration-dropdown-stub + data-testid="older-than-dropdown" + formoptions="[object Object],[object Object],[object Object],[object Object]" + label="Remove tags older than:" + name="older-than" + value="FOURTEEN_DAYS" +/> +`; + +exports[`Settings Form Remove regex matches snapshot 1`] = ` +<expiration-input-stub + data-testid="remove-regex-input" + description="Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}" + error="" + label="Remove tags matching:" + name="remove-regex" + placeholder=".*" + value="asdasdssssdfdf" +/> +`; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js new file mode 100644 index 00000000000..c56244a9138 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js @@ -0,0 +1,86 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/expiration_dropdown.vue'; + +describe('ExpirationDropdown', () => { + let wrapper; + + const defaultProps = { + name: 'foo', + label: 'label-bar', + formOptions: [ + { key: 'foo', label: 'bar' }, + { key: 'baz', label: 'zab' }, + ], + }; + + const findFormSelect = () => wrapper.find(GlFormSelect); + const findFormGroup = () => wrapper.find(GlFormGroup); + const findOptions = () => wrapper.findAll('[data-testid="option"]'); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + GlFormSelect, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a form-select component', () => { + mountComponent(); + expect(findFormSelect().exists()).toBe(true); + }); + + it('has the correct options', () => { + mountComponent(); + + expect(findOptions()).toHaveLength(defaultProps.formOptions.length); + }); + }); + + describe('model', () => { + it('assign the right props to the form-select component', () => { + const value = 'foobar'; + const disabled = true; + + mountComponent({ value, disabled }); + + expect(findFormSelect().props()).toMatchObject({ + value, + disabled, + }); + expect(findFormSelect().attributes('id')).toBe(defaultProps.name); + }); + + it('assign the right props to the form-group component', () => { + mountComponent(); + + expect(findFormGroup().attributes()).toMatchObject({ + id: `${defaultProps.name}-form-group`, + 'label-for': defaultProps.name, + label: defaultProps.label, + }); + }); + + it('emits input event when form-select emits input', () => { + const emittedValue = 'barfoo'; + + mountComponent(); + + findFormSelect().vm.$emit('input', emittedValue); + + expect(wrapper.emitted('input')).toEqual([[emittedValue]]); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js new file mode 100644 index 00000000000..dd876d1d295 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_input_spec.js @@ -0,0 +1,169 @@ +import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/expiration_input.vue'; +import { NAME_REGEX_LENGTH } from '~/packages_and_registries/settings/project/constants'; + +describe('ExpirationInput', () => { + let wrapper; + + const defaultProps = { + name: 'foo', + label: 'label-bar', + placeholder: 'placeholder-baz', + description: '%{linkStart}description-foo%{linkEnd}', + }; + + const tagsRegexHelpPagePath = 'fooPath'; + + const findInput = () => wrapper.find(GlFormInput); + const findFormGroup = () => wrapper.find(GlFormGroup); + const findLabel = () => wrapper.find('[data-testid="label"]'); + const findDescription = () => wrapper.find('[data-testid="description"]'); + const findDescriptionLink = () => wrapper.find(GlLink); + + const mountComponent = (props) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + GlFormGroup, + }, + provide: { + tagsRegexHelpPagePath, + }, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a label', () => { + mountComponent(); + + expect(findLabel().text()).toBe(defaultProps.label); + }); + + it('has a textarea component', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + }); + + it('has a description', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(defaultProps.description); + }); + + it('has a description link', () => { + mountComponent(); + + const link = findDescriptionLink(); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(tagsRegexHelpPagePath); + }); + }); + + describe('model', () => { + it('assigns the right props to the textarea component', () => { + const value = 'foobar'; + const disabled = true; + + mountComponent({ value, disabled }); + + expect(findInput().attributes()).toMatchObject({ + id: defaultProps.name, + value, + placeholder: defaultProps.placeholder, + disabled: `${disabled}`, + trim: '', + }); + }); + + it('emits input event when textarea emits input', () => { + const emittedValue = 'barfoo'; + + mountComponent(); + + findInput().vm.$emit('input', emittedValue); + expect(wrapper.emitted('input')).toEqual([[emittedValue]]); + }); + }); + + describe('regex textarea validation', () => { + const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); + + describe('when error contains an error message', () => { + const errorMessage = 'something went wrong'; + + it('shows the error message on the relevant field', () => { + mountComponent({ error: errorMessage }); + + expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage); + }); + + it('gives precedence to API errors compared to local ones', () => { + mountComponent({ + error: errorMessage, + value: invalidString, + }); + + expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage); + }); + }); + + describe('when error is empty', () => { + describe('if the user did not type', () => { + it('validation is not emitted', () => { + mountComponent(); + + expect(wrapper.emitted('validation')).toBeUndefined(); + }); + + it('no error message is shown', () => { + mountComponent(); + + expect(findFormGroup().props('state')).toBe(true); + expect(findFormGroup().attributes('invalid-feedback')).toBe(''); + }); + }); + + describe('when the user typed something', () => { + describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { + beforeEach(() => { + // since the component has no state we both emit the event and set the prop + mountComponent({ value: invalidString }); + + findInput().vm.$emit('input', invalidString); + }); + + it('textAreaValidation state is false', () => { + expect(findFormGroup().props('state')).toBe(false); + expect(findInput().attributes('state')).toBeUndefined(); + }); + + it('emits the @validation event with false payload', () => { + expect(wrapper.emitted('validation')).toEqual([[false]]); + }); + }); + + it(`when user input is less than ${NAME_REGEX_LENGTH} state is "true"`, () => { + mountComponent(); + + findInput().vm.$emit('input', 'foo'); + + expect(findFormGroup().props('state')).toBe(true); + expect(findInput().attributes('state')).toBe('true'); + expect(wrapper.emitted('validation')).toEqual([[true]]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js new file mode 100644 index 00000000000..854830391c5 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_run_text_spec.js @@ -0,0 +1,65 @@ +import { GlFormInput } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/expiration_run_text.vue'; +import { + NEXT_CLEANUP_LABEL, + NOT_SCHEDULED_POLICY_TEXT, +} from '~/packages_and_registries/settings/project/constants'; + +describe('ExpirationToggle', () => { + let wrapper; + const value = 'foo'; + + const findInput = () => wrapper.find(GlFormInput); + const findFormGroup = () => wrapper.find(GlFormGroup); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has an input component', () => { + mountComponent(); + + expect(findInput().exists()).toBe(true); + }); + }); + + describe('model', () => { + it('assigns the right props to the form-group component', () => { + mountComponent(); + + expect(findFormGroup().attributes()).toMatchObject({ + label: NEXT_CLEANUP_LABEL, + }); + }); + }); + + describe('formattedValue', () => { + it.each` + valueProp | enabled | expected + ${value} | ${true} | ${value} + ${value} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${true} | ${NOT_SCHEDULED_POLICY_TEXT} + `( + 'when value is $valueProp and enabled is $enabled the input value is $expected', + ({ valueProp, enabled, expected }) => { + mountComponent({ value: valueProp, enabled }); + + expect(findInput().attributes('value')).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js new file mode 100644 index 00000000000..3a3eb089b43 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_toggle_spec.js @@ -0,0 +1,77 @@ +import { GlToggle, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormGroup } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/expiration_toggle.vue'; +import { + ENABLED_TOGGLE_DESCRIPTION, + DISABLED_TOGGLE_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; + +describe('ExpirationToggle', () => { + let wrapper; + + const findToggle = () => wrapper.find(GlToggle); + const findDescription = () => wrapper.find('[data-testid="description"]'); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + stubs: { + GlFormGroup, + GlSprintf, + }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('structure', () => { + it('has a toggle component', () => { + mountComponent(); + + expect(findToggle().props('label')).toBe(component.i18n.toggleLabel); + }); + + it('has a description', () => { + mountComponent(); + + expect(findDescription().exists()).toBe(true); + }); + }); + + describe('model', () => { + it('assigns the right props to the toggle component', () => { + mountComponent({ value: true, disabled: true }); + + expect(findToggle().props()).toMatchObject({ + value: true, + disabled: true, + }); + }); + + it('emits input event when toggle is updated', () => { + mountComponent(); + + findToggle().vm.$emit('change', false); + + expect(wrapper.emitted('input')).toEqual([[false]]); + }); + }); + + describe('toggle description', () => { + it('says enabled when the toggle is on', () => { + mountComponent({ value: true }); + + expect(findDescription().text()).toMatchInterpolatedText(ENABLED_TOGGLE_DESCRIPTION); + }); + + it('says disabled when the toggle is off', () => { + mountComponent({ value: false }); + + expect(findDescription().text()).toMatchInterpolatedText(DISABLED_TOGGLE_DESCRIPTION); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js new file mode 100644 index 00000000000..a725941f7f6 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -0,0 +1,164 @@ +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue'; +import SettingsForm from '~/packages_and_registries/settings/project/components/settings_form.vue'; +import { + FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; + +import { + expirationPolicyPayload, + emptyExpirationPolicyPayload, + containerExpirationPolicyData, +} from '../mock_data'; + +const localVue = createLocalVue(); + +describe('Registry Settings App', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + isAdmin: false, + adminSettingsPath: 'settingsPath', + enableHistoricEntries: false, + }; + + const findSettingsComponent = () => wrapper.find(SettingsForm); + const findAlert = () => wrapper.find(GlAlert); + + const mountComponent = (provide = defaultProvidedValues, config) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + }, + mocks: { + $toast: { + show: jest.fn(), + }, + }, + provide, + ...config, + }); + }; + + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[expirationPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + localVue, + apolloProvider: fakeApollo, + }); + + return requestHandlers.map((request) => request[1]); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('isEdited status', () => { + it.each` + description | apiResponse | workingCopy | result + ${'empty response and no changes from user'} | ${emptyExpirationPolicyPayload()} | ${{}} | ${false} + ${'empty response and changes from user'} | ${emptyExpirationPolicyPayload()} | ${{ enabled: true }} | ${true} + ${'response and no changes'} | ${expirationPolicyPayload()} | ${containerExpirationPolicyData()} | ${false} + ${'response and changes'} | ${expirationPolicyPayload()} | ${{ ...containerExpirationPolicyData(), nameRegex: '12345' }} | ${true} + ${'response and empty'} | ${expirationPolicyPayload()} | ${{}} | ${true} + `('$description', async ({ apiResponse, workingCopy, result }) => { + const requests = mountComponentWithApollo({ + provide: { ...defaultProvidedValues, enableHistoricEntries: true }, + resolver: jest.fn().mockResolvedValue(apiResponse), + }); + await Promise.all(requests); + + findSettingsComponent().vm.$emit('input', workingCopy); + + await wrapper.vm.$nextTick(); + + expect(findSettingsComponent().props('isEdited')).toBe(result); + }); + }); + + it('renders the setting form', async () => { + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), + }); + await Promise.all(requests); + + expect(findSettingsComponent().exists()).toBe(true); + }); + + describe('the form is disabled', () => { + it('the form is hidden', () => { + mountComponent(); + + expect(findSettingsComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + mountComponent(); + + const text = findAlert().text(); + expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); + expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); + }); + + describe('an admin is visiting the page', () => { + it('shows the admin part of the alert message', () => { + mountComponent({ ...defaultProvidedValues, isAdmin: true }); + + const sprintf = findAlert().find(GlSprintf); + expect(sprintf.text()).toBe('administration settings'); + expect(sprintf.find(GlLink).attributes('href')).toBe( + defaultProvidedValues.adminSettingsPath, + ); + }); + }); + }); + + describe('fetchSettingsError', () => { + beforeEach(() => { + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + return Promise.all(requests); + }); + + it('the form is hidden', () => { + expect(findSettingsComponent().exists()).toBe(false); + }); + + it('shows an alert', () => { + expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); + }); + }); + + describe('empty API response', () => { + it.each` + enableHistoricEntries | isShown + ${true} | ${true} + ${false} | ${false} + `('is $isShown that the form is shown', async ({ enableHistoricEntries, isShown }) => { + const requests = mountComponentWithApollo({ + provide: { + ...defaultProvidedValues, + enableHistoricEntries, + }, + resolver: jest.fn().mockResolvedValue(emptyExpirationPolicyPayload()), + }); + await Promise.all(requests); + + expect(findSettingsComponent().exists()).toBe(isShown); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js new file mode 100644 index 00000000000..7e5383d7ff1 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -0,0 +1,460 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GlCard, GlLoadingIcon } from 'jest/registry/shared/stubs'; +import component from '~/packages_and_registries/settings/project/components/settings_form.vue'; +import { + UPDATE_SETTINGS_ERROR_MESSAGE, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '~/packages_and_registries/settings/project/constants'; +import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import Tracking from '~/tracking'; +import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; + +const localVue = createLocalVue(); + +describe('Settings Form', () => { + let wrapper; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const { + data: { + project: { containerExpirationPolicy }, + }, + } = expirationPolicyPayload(); + + const defaultProps = { + value: { ...containerExpirationPolicy }, + }; + + const trackingPayload = { + label: 'docker_container_retention_and_expiration_policies', + }; + + const findForm = () => wrapper.find({ ref: 'form-element' }); + + const findCancelButton = () => wrapper.find('[data-testid="cancel-button"'); + const findSaveButton = () => wrapper.find('[data-testid="save-button"'); + const findEnableToggle = () => wrapper.find('[data-testid="enable-toggle"]'); + const findCadenceDropdown = () => wrapper.find('[data-testid="cadence-dropdown"]'); + const findKeepNDropdown = () => wrapper.find('[data-testid="keep-n-dropdown"]'); + const findKeepRegexInput = () => wrapper.find('[data-testid="keep-regex-input"]'); + const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]'); + const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]'); + + const mountComponent = ({ + props = defaultProps, + data, + config, + provide = defaultProvidedValues, + mocks, + } = {}) => { + wrapper = shallowMount(component, { + stubs: { + GlCard, + GlLoadingIcon, + }, + propsData: { ...props }, + provide, + data() { + return { + ...data, + }; + }, + mocks: { + $toast: { + show: jest.fn(), + }, + ...mocks, + }, + ...config, + }); + }; + + const mountComponentWithApollo = ({ + provide = defaultProvidedValues, + mutationResolver, + queryPayload = expirationPolicyPayload(), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [updateContainerExpirationPolicyMutation, mutationResolver], + [expirationPolicyQuery, jest.fn().mockResolvedValue(queryPayload)], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + // This component does not do the query directly, but we need a proper cache to update + fakeApollo.defaultClient.cache.writeQuery({ + query: expirationPolicyQuery, + variables: { + projectPath: provide.projectPath, + }, + ...queryPayload, + }); + + // we keep in sync what prop we pass to the component with the cache + const { + data: { + project: { containerExpirationPolicy: value }, + }, + } = queryPayload; + + mountComponent({ + provide, + props: { + ...defaultProps, + value, + }, + config: { + localVue, + apolloProvider: fakeApollo, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + model | finder | fieldName | type | defaultValue + ${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false} + ${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'} + ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'} + ${'nameRegexKeep'} | ${findKeepRegexInput} | ${'Keep Regex'} | ${'textarea'} | ${''} + ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'} + ${'nameRegex'} | ${findRemoveRegexInput} | ${'Remove regex'} | ${'textarea'} | ${''} + `('$fieldName', ({ model, finder, type, defaultValue }) => { + it('matches snapshot', () => { + mountComponent(); + + expect(finder().element).toMatchSnapshot(); + }); + + it('input event triggers a model update', () => { + mountComponent(); + + finder().vm.$emit('input', 'foo'); + expect(wrapper.emitted('input')[0][0]).toMatchObject({ + [model]: 'foo', + }); + }); + + it('shows the default option when none are selected', () => { + mountComponent({ props: { value: {} } }); + expect(finder().props('value')).toEqual(defaultValue); + }); + + if (type !== 'toggle') { + it.each` + isLoading | mutationLoading | enabledValue + ${false} | ${false} | ${false} + ${true} | ${false} | ${false} + ${true} | ${true} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'is disabled when is loading is $isLoading, mutationLoading is $mutationLoading and enabled is $enabledValue', + ({ isLoading, mutationLoading, enabledValue }) => { + mountComponent({ + props: { isLoading, value: { enabled: enabledValue } }, + data: { mutationLoading }, + }); + expect(finder().props('disabled')).toEqual(true); + }, + ); + } else { + it.each` + isLoading | mutationLoading + ${true} | ${false} + ${true} | ${true} + ${false} | ${true} + `( + 'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading', + ({ isLoading, mutationLoading }) => { + mountComponent({ + props: { isLoading, value: {} }, + data: { mutationLoading }, + }); + expect(finder().props('disabled')).toEqual(true); + }, + ); + } + + if (type === 'textarea') { + it('input event updates the api error property', async () => { + const apiErrors = { [model]: 'bar' }; + mountComponent({ data: { apiErrors } }); + + finder().vm.$emit('input', 'foo'); + expect(finder().props('error')).toEqual('bar'); + + await wrapper.vm.$nextTick(); + + expect(finder().props('error')).toEqual(''); + }); + + it('validation event updates buttons disabled state', async () => { + mountComponent(); + + expect(findSaveButton().props('disabled')).toBe(false); + + finder().vm.$emit('validation', false); + + await wrapper.vm.$nextTick(); + + expect(findSaveButton().props('disabled')).toBe(true); + }); + } + + if (type === 'dropdown') { + it('has the correct formOptions', () => { + mountComponent(); + expect(finder().props('formOptions')).toEqual(wrapper.vm.$options.formOptions[model]); + }); + } + }); + + describe('form', () => { + describe('form reset event', () => { + it('calls the appropriate function', () => { + mountComponent(); + + findForm().trigger('reset'); + + expect(wrapper.emitted('reset')).toEqual([[]]); + }); + + it('tracks the reset event', () => { + mountComponent(); + + findForm().trigger('reset'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'reset_form', trackingPayload); + }); + + it('resets the errors objects', async () => { + mountComponent({ + data: { apiErrors: { nameRegex: 'bar' }, localErrors: { nameRegexKeep: false } }, + }); + + findForm().trigger('reset'); + + await wrapper.vm.$nextTick(); + + expect(findKeepRegexInput().props('error')).toBe(''); + expect(findRemoveRegexInput().props('error')).toBe(''); + expect(findSaveButton().props('disabled')).toBe(false); + }); + }); + + describe('form submit event ', () => { + it('save has type submit', () => { + mountComponent(); + + expect(findSaveButton().attributes('type')).toBe('submit'); + }); + + it('dispatches the correct apollo mutation', () => { + const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, + }); + + findForm().trigger('submit'); + + expect(mutationResolver).toHaveBeenCalled(); + }); + + it('saves the default values when a value is missing did not change the default options', async () => { + const mutationResolver = jest.fn().mockResolvedValue(expirationPolicyMutationPayload()); + mountComponentWithApollo({ + mutationResolver, + queryPayload: expirationPolicyPayload({ keepN: null, cadence: null, olderThan: null }), + }); + + await waitForPromises(); + + findForm().trigger('submit'); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'NINETY_DAYS', + projectPath: 'path', + }, + }); + }); + + it('tracks the submit event', () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); + }); + + it('show a success toast when submit succeed', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { + type: 'success', + }); + }); + + describe('when submit fails', () => { + describe('user recoverable errors', () => { + it('when there is an error is shown in a toast', async () => { + mountComponentWithApollo({ + mutationResolver: jest + .fn() + .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })), + }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', { + type: 'error', + }); + }); + }); + + describe('global errors', () => { + it('shows an error', async () => { + mountComponentWithApollo({ + mutationResolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { + type: 'error', + }); + }); + + it('parses the error messages', async () => { + const mutate = jest.fn().mockRejectedValue({ + graphQLErrors: [ + { + extensions: { + problems: [{ path: ['nameRegexKeep'], message: 'baz' }], + }, + }, + ], + }); + mountComponent({ mocks: { $apollo: { mutate } } }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(findKeepRegexInput().props('error')).toEqual('baz'); + }); + }); + }); + }); + }); + + describe('form actions', () => { + describe('cancel button', () => { + it('has type reset', () => { + mountComponent(); + + expect(findCancelButton().attributes('type')).toBe('reset'); + }); + + it.each` + isLoading | isEdited | mutationLoading + ${true} | ${true} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} + ${true} | ${false} | ${false} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading, isEdited is $isEdited and mutationLoading is $mutationLoading is disabled', + ({ isEdited, isLoading, mutationLoading }) => { + mountComponent({ + props: { ...defaultProps, isEdited, isLoading }, + data: { mutationLoading }, + }); + + expect(findCancelButton().props('disabled')).toBe(true); + }, + ); + }); + + describe('submit button', () => { + it('has type submit', () => { + mountComponent(); + + expect(findSaveButton().attributes('type')).toBe('submit'); + }); + + it.each` + isLoading | localErrors | mutationLoading + ${true} | ${{}} | ${true} + ${true} | ${{}} | ${false} + ${false} | ${{}} | ${true} + ${false} | ${{ foo: false }} | ${true} + ${true} | ${{ foo: false }} | ${false} + ${false} | ${{ foo: false }} | ${false} + `( + 'when isLoading is $isLoading, localErrors is $localErrors and mutationLoading is $mutationLoading is disabled', + ({ localErrors, isLoading, mutationLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading, localErrors }, + }); + + expect(findSaveButton().props('disabled')).toBe(true); + }, + ); + + it.each` + isLoading | mutationLoading | showLoading + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown', + ({ isLoading, mutationLoading, showLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('loading')).toBe(showLoading); + }, + ); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js new file mode 100644 index 00000000000..4d6bd65bd93 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/graphql/cache_updated_spec.js @@ -0,0 +1,56 @@ +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; +import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update'; + +describe('Registry settings cache update', () => { + let client; + + const payload = { + data: { + updateContainerExpirationPolicy: { + containerExpirationPolicy: { + enabled: true, + }, + }, + }, + }; + + const cacheMock = { + project: { + containerExpirationPolicy: { + enabled: false, + }, + }, + }; + + const queryAndVariables = { + query: expirationPolicyQuery, + variables: { projectPath: 'foo' }, + }; + + beforeEach(() => { + client = { + readQuery: jest.fn().mockReturnValue(cacheMock), + writeQuery: jest.fn(), + }; + }); + describe('Registry settings cache update', () => { + it('calls readQuery', () => { + updateContainerExpirationPolicy('foo')(client, payload); + expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); + }); + + it('writes the correct result in the cache', () => { + updateContainerExpirationPolicy('foo')(client, payload); + expect(client.writeQuery).toHaveBeenCalledWith({ + ...queryAndVariables, + data: { + project: { + containerExpirationPolicy: { + enabled: true, + }, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js new file mode 100644 index 00000000000..9778f409010 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js @@ -0,0 +1,40 @@ +export const containerExpirationPolicyData = () => ({ + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + nextRunAt: '2020-11-19T07:37:03.941Z', +}); + +export const expirationPolicyPayload = (override) => ({ + data: { + project: { + containerExpirationPolicy: { + ...containerExpirationPolicyData(), + ...override, + }, + }, + }, +}); + +export const emptyExpirationPolicyPayload = () => ({ + data: { + project: { + containerExpirationPolicy: {}, + }, + }, +}); + +export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + updateContainerExpirationPolicy: { + containerExpirationPolicy: { + ...containerExpirationPolicyData(), + ...override, + }, + errors, + }, + }, +}); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js new file mode 100644 index 00000000000..4c81671cd46 --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js @@ -0,0 +1,37 @@ +import { + formOptionsGenerator, + optionLabelGenerator, + olderThanTranslationGenerator, +} from '~/packages_and_registries/settings/project/utils'; + +describe('Utils', () => { + describe('optionLabelGenerator', () => { + it('returns an array with a set label', () => { + const result = optionLabelGenerator( + [{ variable: 1 }, { variable: 2 }], + olderThanTranslationGenerator, + ); + expect(result).toEqual([ + { variable: 1, label: '1 day' }, + { variable: 2, label: '2 days' }, + ]); + }); + }); + + describe('formOptionsGenerator', () => { + it('returns an object containing olderThan', () => { + expect(formOptionsGenerator().olderThan).toBeDefined(); + expect(formOptionsGenerator().olderThan).toMatchSnapshot(); + }); + + it('returns an object containing cadence', () => { + expect(formOptionsGenerator().cadence).toBeDefined(); + expect(formOptionsGenerator().cadence).toMatchSnapshot(); + }); + + it('returns an object containing keepN', () => { + expect(formOptionsGenerator().keepN).toBeDefined(); + expect(formOptionsGenerator().keepN).toMatchSnapshot(); + }); + }); +}); |