diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-30 15:12:24 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-30 15:12:24 +0000 |
commit | eaec42f9e37fe51f9c53fa7079639ec9f4c40efc (patch) | |
tree | 2abbab9659bc8e043b2dbb9dcf797a5aab717767 /spec | |
parent | 4e8c8922da341914b9fd5570ec9ce7a29ffdfebd (diff) | |
download | gitlab-ce-eaec42f9e37fe51f9c53fa7079639ec9f4c40efc.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
17 files changed, 695 insertions, 409 deletions
diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb index e75e661b513..7390edc3c47 100644 --- a/spec/features/dashboard/activity_spec.rb +++ b/spec/features/dashboard/activity_spec.rb @@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do it 'shows Your Projects' do visit activity_dashboard_path - expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Your projects') end it 'shows Starred Projects' do visit activity_dashboard_path(filter: 'starred') - expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Starred projects') end it 'shows Followed Projects' do visit activity_dashboard_path(filter: 'followed') - expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users') + expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Followed users') end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 39f9d3b331b..dacbaa826a0 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do expect(git_import_instructions).to have_content 'Git repository URL' end - it 'reports error if repo URL does not end with .git' do + it 'reports error if repo URL is not a valid Git repository' do + stub_request(:get, "http://foo/bar/info/refs?service=git-upload-pack").to_return(status: 200, body: "not-a-git-repo") + fill_in 'project_import_url', with: 'http://foo/bar' # simulate blur event find('body').click - expect(page).to have_text('A repository URL usually ends in a .git suffix') + wait_for_requests + + expect(page).to have_text('There is not a valid Git repository at this URL') end it 'keeps "Import project" tab open after form validation error' do diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 2537b8fb816..36b81b3eb28 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; @@ -25,6 +26,17 @@ const projects = [ }, ]; +const MockGlDropdown = stubComponent(GlDropdown, { + template: ` + <div> + <div data-testid="vsa-highlighted-items"> + <slot name="highlighted-items"></slot> + </div> + <div data-testid="vsa-default-items"><slot></slot></div> + </div> + `, +}); + const defaultMocks = { $apollo: { query: jest.fn().mockResolvedValue({ @@ -38,22 +50,32 @@ let spyQuery; describe('ProjectsDropdownFilter component', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { spyQuery = defaultMocks.$apollo.query; - wrapper = mount(ProjectsDropdownFilter, { + wrapper = mountExtended(ProjectsDropdownFilter, { mocks: { ...defaultMocks }, propsData: { groupId: 1, groupNamespace: 'gitlab-org', ...props, }, + stubs, }); }; + const createWithMockDropdown = (props) => { + createComponent(props, { GlDropdown: MockGlDropdown }); + return wrapper.vm.$nextTick(); + }; + afterEach(() => { wrapper.destroy(); }); + const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items'); + const findHighlightedItemsTitle = () => wrapper.findByText('Selected'); + const findClearAllButton = () => wrapper.findByText('Clear all'); + const findDropdown = () => wrapper.find(GlDropdown); const findDropdownItems = () => @@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => { const findDropdownFullPathAtIndex = (index) => findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); - const selectDropdownItemAtIndex = (index) => + const selectDropdownItemAtIndex = (index) => { findDropdownAtIndex(index).find('button').trigger('click'); + return wrapper.vm.$nextTick(); + }; + + // NOTE: Selected items are now visually separated from unselected items + const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem); + + const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index); + const findSelectedButtonIdentIconAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon'); + const findSelectedButtonAvatarItemAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('img.gl-avatar'); const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); @@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => { }); }); - describe('when passed a an array of defaultProject as prop', () => { + describe('highlighted items', () => { + const blockDefaultProps = { multiSelect: true }; + beforeEach(() => { + createComponent(blockDefaultProps); + }); + + describe('with no project selected', () => { + it('does not render the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + expect(findSelectedDropdownItems().length).toBe(0); + }); + + it('does not render the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(false); + }); + + it('does not render the clear all button', () => { + expect(findClearAllButton().exists()).toBe(false); + }); + }); + + describe('with a selected project', () => { + beforeEach(async () => { + await selectDropdownItemAtIndex(0); + }); + + it('renders the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); + + expect(findSelectedDropdownItems().length).toBe(1); + }); + + it('renders the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(true); + }); + + it('renders the clear all button', () => { + expect(findClearAllButton().exists()).toBe(true); + }); + + it('clears all selected items when the clear all button is clicked', async () => { + await selectDropdownItemAtIndex(1); + + expect(wrapper.text()).toContain('2 projects selected'); + + findClearAllButton().trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain('2 projects selected'); + expect(wrapper.text()).toContain('Select projects'); + }); + }); + }); + + describe('when passed an array of defaultProject as prop', () => { beforeEach(() => { createComponent({ defaultProjects: [projects[0]], @@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => { }); describe('when multiSelect is false', () => { + const blockDefaultProps = { multiSelect: false }; beforeEach(() => { - createComponent({ multiSelect: false }); + createComponent(blockDefaultProps); }); describe('displays the correct information', () => { @@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { - selectDropdownItemAtIndex(0); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); - expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false); }); it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { - selectDropdownItemAtIndex(1); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(1); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); - expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true); }); }); }); 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 f2877a1f2a5..02f9451152f 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 @@ -1,28 +1,20 @@ -import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; 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 PackagesSettings from '~/packages_and_registries/settings/group/components/packages_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 { - PACKAGE_SETTINGS_HEADER, - PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, ERROR_UPDATING_SETTINGS, SUCCESS_UPDATING_SETTINGS, } from '~/packages_and_registries/settings/group/constants'; -import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import { - groupPackageSettingsMock, - groupPackageSettingsMutationMock, - groupPackageSettingsMutationErrorMock, -} from '../mock_data'; +import { groupPackageSettingsMock, packageSettings } from '../mock_data'; jest.mock('~/flash'); @@ -39,35 +31,18 @@ describe('Group Settings App', () => { }; const mountComponent = ({ - provide = defaultProvide, resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock), - mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), - data = {}, } = {}) => { localVue.use(VueApollo); - const requestHandlers = [ - [getGroupPackagesSettingsQuery, resolver], - [updateNamespacePackageSettings, mutationResolver], - ]; + const requestHandlers = [[getGroupPackagesSettingsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); wrapper = shallowMount(component, { localVue, apolloProvider, - provide, - data() { - return { - ...data, - }; - }, - stubs: { - GlSprintf, - SettingsBlock, - MavenSettings, - GenericSettings, - }, + provide: defaultProvide, mocks: { $toast: { show, @@ -84,271 +59,73 @@ describe('Group Settings App', () => { wrapper.destroy(); }); - const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); - const findDescription = () => wrapper.find('[data-testid="description"'); - 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 findPackageSettings = () => wrapper.findComponent(PackagesSettings); const waitForApolloQueryAndRender = async () => { await waitForPromises(); - await wrapper.vm.$nextTick(); - }; - - const emitSettingsUpdate = (override) => { - findMavenDuplicatedSettings().vm.$emit('update', { - mavenDuplicateExceptionRegex: ')', - ...override, - }); + await nextTick(); }; - it('renders a settings block', () => { - mountComponent(); - - expect(findSettingsBlock().exists()).toBe(true); - }); - - it('passes the correct props to settings block', () => { - mountComponent(); - - expect(findSettingsBlock().props('defaultExpanded')).toBe(false); - }); - - it('has the correct header text', () => { - mountComponent(); - - expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); - }); - - it('has the correct description text', () => { - mountComponent(); - - expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); - }); - - it('has the correct link', () => { - mountComponent(); - - expect(findLink().attributes()).toMatchObject({ - href: PACKAGES_DOCS_PATH, - target: '_blank', - }); - expect(findLink().text()).toBe('Learn more.'); - }); - - it('calls the graphql API with the proper variables', () => { - const resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock); - mountComponent({ resolver }); - - expect(resolver).toHaveBeenCalledWith({ - fullPath: defaultProvide.groupPath, - }); - }); - - describe('maven settings', () => { - it('exists', () => { - mountComponent(); - - expect(findMavenSettings().exists()).toBe(true); - }); - - it('assigns duplication allowness and exception props', async () => { - mountComponent(); - - expect(findMavenDuplicatedSettings().props('loading')).toBe(true); - - await waitForApolloQueryAndRender(); - - const { - mavenDuplicatesAllowed, - mavenDuplicateExceptionRegex, - } = groupPackageSettingsMock.data.group.packageSettings; - - expect(findMavenDuplicatedSettings().props()).toMatchObject({ - duplicatesAllowed: mavenDuplicatesAllowed, - duplicateExceptionRegex: mavenDuplicateExceptionRegex, - duplicateExceptionRegexError: '', - loading: false, - }); - }); - - it('on update event calls the mutation', async () => { - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - expect(mutationResolver).toHaveBeenCalledWith({ - input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, - }); - }); - }); - - describe('generic settings', () => { - it('exists', () => { + describe.each` + finder | entityProp | entityValue + ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()} + `('settings blocks', ({ finder, entityProp, entityValue }) => { + beforeEach(() => { mountComponent(); - - expect(findGenericSettings().exists()).toBe(true); + return waitForApolloQueryAndRender(); }); - 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('renders the settings block', () => { + expect(finder().exists()).toBe(true); }); - 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' }, + it('binds the correctProps', () => { + expect(finder().props()).toMatchObject({ + isLoading: false, + [entityProp]: entityValue, }); }); - }); - - describe('settings update', () => { - describe('success state', () => { - it('shows a success alert', async () => { - mountComponent(); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); + describe('success event', () => { + it('shows a success toast', () => { + finder().vm.$emit('success'); expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS); }); - it('has an optimistic response', async () => { - const mavenDuplicateExceptionRegex = 'latest[main]something'; - mountComponent(); - - await waitForApolloQueryAndRender(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); - - emitSettingsUpdate({ mavenDuplicateExceptionRegex }); - - // wait for apollo to update the model with the optimistic response - await wrapper.vm.$nextTick(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); - - // wait for the call to resolve - await waitForPromises(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); - }); - }); + it('hides the error alert', async () => { + finder().vm.$emit('error'); + await nextTick(); - describe('errors', () => { - const verifyAlert = () => { expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS); - expect(findAlert().props('variant')).toBe('warning'); - }; - - it('mutation payload with root level errors', async () => { - // note this is a complex test that covers all the path around errors that are shown in the form - // it's one single it case, due to the expensive preparation and execution - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForApolloQueryAndRender(); - - // errors are bound to the component - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( - groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, - ); - // general error message is shown + finder().vm.$emit('success'); + await nextTick(); - verifyAlert(); - - emitSettingsUpdate(); - - await wrapper.vm.$nextTick(); - - // errors are reset on mutation call - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); + expect(findAlert().exists()).toBe(false); }); + }); - it.each` - type | mutationResolver - ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} - ${'network'} | ${jest.fn().mockRejectedValue()} - `('mutation payload with $type error', async ({ mutationResolver }) => { - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); - - verifyAlert(); + describe('error event', () => { + beforeEach(() => { + finder().vm.$emit('error'); + return nextTick(); }); - it('a successful request dismisses the alert', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); - + it('shows an alert', () => { expect(findAlert().exists()).toBe(true); - - emitSettingsUpdate(); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(false); }); - it('dismiss event from alert dismiss it from the page', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); + it('alert has the right text', () => { + expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS); + }); + it('dismissing the alert removes it', async () => { expect(findAlert().exists()).toBe(true); findAlert().vm.$emit('dismiss'); - await wrapper.vm.$nextTick(); + await nextTick(); expect(findAlert().exists()).toBe(false); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js new file mode 100644 index 00000000000..693af21e24a --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -0,0 +1,277 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +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/packages_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + PACKAGES_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + packageSettings, + groupPackageSettingsMock, + groupPackageSettingsMutationMock, + groupPackageSettingsMutationErrorMock, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +const localVue = createLocalVue(); + +describe('Packages Settings', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + defaultExpanded: false, + groupPath: 'foo_group_path', + }; + + const mountComponent = ({ + mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(component, { + localVue, + apolloProvider, + provide: defaultProvide, + propsData: { + packageSettings: packageSettings(), + }, + stubs: { + GlSprintf, + SettingsBlock, + MavenSettings, + GenericSettings, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findDescription = () => wrapper.findByTestId('description'); + const findLink = () => wrapper.findComponent(GlLink); + const findMavenSettings = () => wrapper.findComponent(MavenSettings); + const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); + const findGenericSettings = () => wrapper.findComponent(GenericSettings); + const findGenericDuplicatedSettings = () => + findGenericSettings().findComponent(DuplicatesSettings); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const emitMavenSettingsUpdate = (override) => { + findMavenDuplicatedSettings().vm.$emit('update', { + mavenDuplicateExceptionRegex: ')', + ...override, + }); + }; + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes the correct props to settings block', () => { + mountComponent(); + + expect(findSettingsBlock().props('defaultExpanded')).toBe(false); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); + }); + + it('has the correct link', () => { + mountComponent(); + + expect(findLink().attributes()).toMatchObject({ + href: PACKAGES_DOCS_PATH, + target: '_blank', + }); + expect(findLink().text()).toBe('Learn more.'); + }); + + describe('maven settings', () => { + it('exists', () => { + mountComponent(); + + expect(findMavenSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings(); + + expect(findMavenDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: mavenDuplicatesAllowed, + duplicateExceptionRegex: mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('generic settings', () => { + it('exists', () => { + mountComponent(); + + expect(findGenericSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = 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 }); + + fillApolloCache(); + + findMavenDuplicatedSettings().vm.$emit('update', { + genericDuplicateExceptionRegex: ')', + }); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('settings update', () => { + describe('success state', () => { + it('emits a success event', async () => { + mountComponent(); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('success')).toEqual([[]]); + }); + + it('has an optimistic response', () => { + const mavenDuplicateExceptionRegex = 'latest[main]something'; + mountComponent(); + + fillApolloCache(); + + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); + + emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex }); + + expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ + ...packageSettings(), + mavenDuplicateExceptionRegex, + }); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + // note this is a complex test that covers all the path around errors that are shown in the form + // it's one single it case, due to the expensive preparation and execution + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + await waitForPromises(); + + // errors are bound to the component + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( + groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, + ); + + // general error message is shown + + expect(wrapper.emitted('error')).toEqual([[]]); + + emitMavenSettingsUpdate(); + + await wrapper.vm.$nextTick(); + + // errors are reset on mutation call + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + }); + }); +}); 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 65119e288a1..917dbfddd64 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -1,12 +1,15 @@ +export const packageSettings = () => ({ + mavenDuplicatesAllowed: true, + mavenDuplicateExceptionRegex: '', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: '', +}); + export const groupPackageSettingsMock = { data: { group: { - packageSettings: { - mavenDuplicatesAllowed: true, - mavenDuplicateExceptionRegex: '', - genericDuplicatesAllowed: true, - genericDuplicateExceptionRegex: '', - }, + fullPath: 'foo_group_path', + packageSettings: packageSettings(), }, }, }; diff --git a/spec/lib/banzai/cross_project_reference_spec.rb b/spec/lib/banzai/cross_project_reference_spec.rb index 60ff15a88e0..e703bbc4927 100644 --- a/spec/lib/banzai/cross_project_reference_spec.rb +++ b/spec/lib/banzai/cross_project_reference_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe Banzai::CrossProjectReference do let(:including_class) { Class.new.include(described_class).new } - let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {})} + let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {}, {})} before do allow(including_class).to receive(:context).and_return({}) diff --git a/spec/lib/banzai/filter/references/reference_cache_spec.rb b/spec/lib/banzai/filter/references/reference_cache_spec.rb index c9404c381d3..dcd153da16a 100644 --- a/spec/lib/banzai/filter/references/reference_cache_spec.rb +++ b/spec/lib/banzai/filter/references/reference_cache_spec.rb @@ -12,15 +12,48 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do let(:filter_class) { Banzai::Filter::References::IssueReferenceFilter } let(:filter) { filter_class.new(doc, project: project) } - let(:cache) { described_class.new(filter, { project: project }) } + let(:cache) { described_class.new(filter, { project: project }, result) } + let(:result) { {} } describe '#load_references_per_parent' do + subject { cache.load_references_per_parent(filter.nodes) } + it 'loads references grouped per parent paths' do - cache.load_references_per_parent(filter.nodes) + expect(doc).to receive(:to_html).and_call_original + + subject expect(cache.references_per_parent).to eq({ project.full_path => [issue1.iid, issue2.iid].to_set, project2.full_path => [issue3.iid].to_set }) end + + context 'when rendered_html is memoized' do + let(:result) { { rendered_html: 'html' } } + + it 'reuses memoized rendered HTML when available' do + expect(doc).not_to receive(:to_html) + + subject + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(reference_cache_memoization: false) + end + + it 'ignores memoized rendered HTML' do + expect(doc).to receive(:to_html).and_call_original + + subject + end + end + end + + context 'when result is not available' do + let(:result) { nil } + + it { expect { subject }.not_to raise_error } + end end describe '#load_parent_per_reference' do @@ -47,7 +80,7 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do it 'does not have an N+1 query problem with cross projects' do doc_single = Nokogiri::HTML.fragment("#1") filter_single = filter_class.new(doc_single, project: project) - cache_single = described_class.new(filter_single, { project: project }) + cache_single = described_class.new(filter_single, { project: project }, {}) control_count = ActiveRecord::QueryRecorder.new do cache_single.load_references_per_parent(filter_single.nodes) diff --git a/spec/lib/gitlab/ci/build/auto_retry_spec.rb b/spec/lib/gitlab/ci/build/auto_retry_spec.rb index e83e1326206..fc5999d59ac 100644 --- a/spec/lib/gitlab/ci/build/auto_retry_spec.rb +++ b/spec/lib/gitlab/ci/build/auto_retry_spec.rb @@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do "default for scheduler failure" | 1 | {} | :scheduler_failure | true "quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false "no matching runner" | 0 | { max: 2 } | :no_matching_runner | false + "missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false end with_them do diff --git a/spec/lib/gitlab/ci/config/entry/retry_spec.rb b/spec/lib/gitlab/ci/config/entry/retry_spec.rb index b38387a437e..84ef5344a8b 100644 --- a/spec/lib/gitlab/ci/config/entry/retry_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/retry_spec.rb @@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do api_failure stuck_or_timeout_failure runner_system_failure - missing_dependency_failure runner_unsupported stale_schedule job_execution_timeout diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 14c7f7f227b..a19acbd196d 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1221,32 +1221,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do %w(test success), %w(deploy running)]) end - - context 'when commit status is retried' do - let!(:old_commit_status) do - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'success') - end - - context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do - before do - stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) - - Ci::ProcessPipelineService - .new(pipeline) - .execute - end - - it 'ignores the previous state' do - expect(statuses).to eq([%w(build success), - %w(test success), - %w(deploy running)]) - end - end - end end context 'when there is a stage with warnings' do diff --git a/spec/models/concerns/vulnerability_finding_helpers_spec.rb b/spec/models/concerns/vulnerability_finding_helpers_spec.rb new file mode 100644 index 00000000000..023ecccb520 --- /dev/null +++ b/spec/models/concerns/vulnerability_finding_helpers_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe VulnerabilityFindingHelpers do + let(:cls) do + Class.new do + include VulnerabilityFindingHelpers + + attr_accessor :report_type + + def initialize(report_type) + @report_type = report_type + end + end + end + + describe '#requires_manual_resolution?' do + it 'returns false if the finding does not require manual resolution' do + expect(cls.new('sast').requires_manual_resolution?).to eq(false) + end + + it 'returns true when the finding requires manual resolution' do + expect(cls.new('secret_detection').requires_manual_resolution?).to eq(true) + end + end +end diff --git a/spec/requests/import/url_controller_spec.rb b/spec/requests/import/url_controller_spec.rb new file mode 100644 index 00000000000..63af5e8b469 --- /dev/null +++ b/spec/requests/import/url_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::UrlController do + let_it_be(:user) { create(:user) } + + before do + login_as(user) + end + + describe 'POST #validate' do + it 'reports success when service reports success status' do + allow_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service| + allow(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.success) + end + + post import_url_validate_path, params: { url: 'https://fake.repo' } + + expect(json_response).to eq({ 'success' => true }) + end + + it 'exposes error message when service reports error' do + expect_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service| + expect(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.error(message: 'foobar')) + end + + post import_url_validate_path, params: { url: 'https://fake.repo' } + + expect(json_response).to eq({ 'success' => false, 'message' => 'foobar' }) + end + + context 'with an anonymous user' do + before do + sign_out(user) + end + + it 'redirects to sign-in page' do + post import_url_validate_path + + expect(response).to redirect_to(new_user_session_path) + end + end + end +end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index b5bf0adadaf..404e1bf7c87 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -10,11 +10,9 @@ RSpec.describe Ci::ProcessPipelineService do end let(:pipeline_processing_events_counter) { double(increment: true) } - let(:legacy_update_jobs_counter) { double(increment: true) } let(:metrics) do - double(pipeline_processing_events_counter: pipeline_processing_events_counter, - legacy_update_jobs_counter: legacy_update_jobs_counter) + double(pipeline_processing_events_counter: pipeline_processing_events_counter) end subject { described_class.new(pipeline) } @@ -33,68 +31,4 @@ RSpec.describe Ci::ProcessPipelineService do subject.execute end end - - describe 'updating a list of retried builds' do - let!(:build_retried) { create_build('build') } - let!(:build) { create_build('build') } - let!(:test) { create_build('test') } - - context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do - it 'does not update older builds as retried' do - subject.execute - - expect(all_builds.latest).to contain_exactly(build, build_retried, test) - expect(all_builds.retried).to be_empty - end - end - - context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do - before do - stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false) - end - - it 'returns unique statuses' do - subject.execute - - expect(all_builds.latest).to contain_exactly(build, test) - expect(all_builds.retried).to contain_exactly(build_retried) - end - - it 'increments the counter' do - expect(legacy_update_jobs_counter).to receive(:increment) - - subject.execute - end - - it 'logs the project and pipeline id' do - expect(Gitlab::AppJsonLogger).to receive(:info).with(event: 'update_retried_is_used', - project_id: project.id, - pipeline_id: pipeline.id) - - subject.execute - end - - context 'when the previous build has already retried column true' do - before do - build_retried.update_columns(retried: true) - end - - it 'does not increment the counter' do - expect(legacy_update_jobs_counter).not_to receive(:increment) - - subject.execute - end - end - end - - private - - def create_build(name, **opts) - create(:ci_build, :created, pipeline: pipeline, name: name, **opts) - end - - def all_builds - pipeline.builds.order(:stage_idx, :id) - end - end end diff --git a/spec/services/ci/stuck_builds/drop_running_service_spec.rb b/spec/services/ci/stuck_builds/drop_running_service_spec.rb index d2132914a02..439eb3b7724 100644 --- a/spec/services/ci/stuck_builds/drop_running_service_spec.rb +++ b/spec/services/ci/stuck_builds/drop_running_service_spec.rb @@ -17,20 +17,47 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do job.update!(job_attributes) end - context 'when job is running' do - let(:status) { 'running' } + around do |example| + freeze_time { example.run } + end + + shared_examples 'running builds' do + context 'when job is running' do + let(:status) { 'running' } + let(:outdated_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago - 30.minutes } + let(:fresh_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago + 30.minutes } + + context 'when job is outdated' do + let(:created_at) { outdated_time } + let(:updated_at) { outdated_time } + + it_behaves_like 'job is dropped' + end - context 'when job was updated_at more than an hour ago' do - let(:updated_at) { 2.hours.ago } + context 'when job is fresh' do + let(:created_at) { fresh_time } + let(:updated_at) { fresh_time } - it_behaves_like 'job is dropped' + it_behaves_like 'job is unchanged' + end + + context 'when job freshly updated' do + let(:created_at) { outdated_time } + let(:updated_at) { fresh_time } + + it_behaves_like 'job is unchanged' + end end + end - context 'when job was updated in less than 1 hour ago' do - let(:updated_at) { 30.minutes.ago } + include_examples 'running builds' - it_behaves_like 'job is unchanged' + context 'when ci_new_query_for_running_stuck_jobs flag is disabled' do + before do + stub_feature_flags(ci_new_query_for_running_stuck_jobs: false) end + + include_examples 'running builds' end %w(success skipped failed canceled scheduled pending).each do |status| @@ -51,15 +78,4 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do end end end - - context 'for deleted project' do - let(:status) { 'running' } - let(:updated_at) { 2.days.ago } - - before do - job.project.update!(pending_delete: true) - end - - it_behaves_like 'job is dropped' - end end diff --git a/spec/services/import/validate_remote_git_endpoint_service_spec.rb b/spec/services/import/validate_remote_git_endpoint_service_spec.rb new file mode 100644 index 00000000000..97c8a9f5dd4 --- /dev/null +++ b/spec/services/import/validate_remote_git_endpoint_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Import::ValidateRemoteGitEndpointService do + include StubRequests + + let_it_be(:base_url) { 'http://demo.host/path' } + let_it_be(:endpoint_url) { "#{base_url}/info/refs?service=git-upload-pack" } + let_it_be(:error_message) { "#{base_url} is not a valid HTTP Git repository" } + + describe '#execute' do + let(:valid_response) do + { status: 200, + body: '001e# service=git-upload-pack', + headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } } + end + + it 'correctly handles URLs with fragment' do + allow(Gitlab::HTTP).to receive(:get) + + described_class.new(url: "#{base_url}#somehash").execute + + expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false) + end + + context 'when receiving HTTP response' do + subject { described_class.new(url: base_url) } + + it 'returns success when HTTP response is valid and contains correct payload' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.success?).to be(true) + end + + it 'reports error when status code is not 200' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ status: 301 })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when required header is missing' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ headers: nil })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when body is in invalid format' do + stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ body: 'invalid content' })) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + + it 'reports error when exception is raised' do + stub_full_request(endpoint_url, method: :get).to_raise(SocketError.new('dummy message')) + + result = subject.execute + + expect(result).to be_a(ServiceResponse) + expect(result.error?).to be(true) + expect(result.message).to eq(error_message) + end + end + + it 'passes basic auth when credentials are provided' do + allow(Gitlab::HTTP).to receive(:get) + + described_class.new(url: "#{base_url}#somehash", user: 'user', password: 'password').execute + + expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: { username: 'user', password: 'password' }, stream_body: true, follow_redirects: false) + end + end +end diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index 10068b9c508..b6cf78b9046 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -101,6 +101,27 @@ module FilteredSearchHelpers end end + # Same as `expect_tokens` but works with GlFilteredSearch + def expect_vue_tokens(tokens) + page.within '.gl-search-box-by-click .gl-filtered-search-scrollable' do + token_elements = page.all(:css, '.gl-filtered-search-token') + + tokens.each_with_index do |token, index| + el = token_elements[index] + + expect(el.find('.gl-filtered-search-token-type')).to have_content(token[:name]) + expect(el.find('.gl-filtered-search-token-operator')).to have_content(token[:operator]) if token[:operator].present? + expect(el.find('.gl-filtered-search-token-data')).to have_content(token[:value]) if token[:value].present? + + # gl-emoji content is blank when the emoji unicode is not supported + if token[:emoji_name].present? + selector = %(gl-emoji[data-name="#{token[:emoji_name]}"]) + expect(el.find('.gl-filtered-search-token-data-content')).to have_css(selector) + end + end + end + end + def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=') { name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" } end |