diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-24 21:11:45 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-10-24 21:11:45 +0300 |
commit | 4bb797f25563205cf495f4dd5366e037e88831ab (patch) | |
tree | a345ddbd0e2464067323d3c6fd34960607ef4f44 /spec | |
parent | 40a4f37126bb1a1dd6b6f4b3c0ebb414a3e3908a (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
17 files changed, 620 insertions, 238 deletions
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index 2075dd3e7a7..4510e9e646e 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -30,24 +30,29 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access end let(:expiry_date) { 1.month.from_now.to_date } + let(:group_access) { Gitlab::Access::GUEST } - before do - travel_to Time.now.utc.beginning_of_day - + subject(:update_link) do put( :update, params: { namespace_id: project.namespace.to_param, project_id: project.to_param, id: link.id, - group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date } + group_link: { group_access: group_access, expires_at: expiry_date } }, format: :json ) end + before do + travel_to Time.now.utc.beginning_of_day + end + context 'when `expires_at` is set' do it 'returns correct json response' do + update_link + expect(json_response).to eq({ "expires_in" => controller.helpers.time_ago_with_tooltip(expiry_date), "expires_soon" => false }) end end @@ -56,27 +61,41 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access let(:expiry_date) { nil } it 'returns empty json response' do + update_link + expect(json_response).to be_empty end end + + it "returns an error when link is not updated" do + allow(::Projects::GroupLinks::UpdateService).to receive_message_chain(:new, :execute) + .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found)) + + update_link + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Not Found') + end end describe '#destroy' do let(:group_owner) { create(:user) } + let(:group_access) { Gitlab::Access::DEVELOPER } + let(:format) { :html } - let(:link) do - create(:project_group_link, project: project, group: group, group_access: Gitlab::Access::DEVELOPER) + let!(:link) do + create(:project_group_link, project: project, group: group, group_access: group_access) end subject(:destroy_link) do post(:destroy, params: { namespace_id: project.namespace.to_param, project_id: project.to_param, - id: link.id }) + id: link.id }, format: format) end shared_examples 'success response' do it 'deletes the project group link' do - destroy_link + expect { destroy_link }.to change { project.reload.project_group_links.count } expect(response).to redirect_to(project_project_members_path(project)) expect(response).to have_gitlab_http_status(:found) @@ -119,6 +138,27 @@ RSpec.describe Projects::GroupLinksController, feature_category: :system_access end it_behaves_like 'success response' + + it "returns an error when link is not destroyed" do + allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute) + .and_return(ServiceResponse.error(message: 'The error message')) + + expect { destroy_link }.not_to change { project.reload.project_group_links.count } + expect(flash[:alert]).to eq('The project-group link could not be removed.') + end + + context 'when format is js' do + let(:format) { :js } + + it "returns an error when link is not destroyed" do + allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute) + .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found)) + + expect { destroy_link }.not_to change { project.reload.project_group_links.count } + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Not Found') + end + end end context 'when user is not a project maintainer' do diff --git a/spec/fixtures/api/schemas/entities/merge_request_noteable.json b/spec/fixtures/api/schemas/entities/merge_request_noteable.json index 4b790a2c34b..6f3c29b16e9 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_noteable.json +++ b/spec/fixtures/api/schemas/entities/merge_request_noteable.json @@ -24,13 +24,11 @@ "type": "object", "required": [ "can_create_note", - "can_update", - "can_approve" + "can_update" ], "properties": { "can_create_note": { "type": "boolean" }, - "can_update": { "type": "boolean" }, - "can_approve": { "type": "boolean" } + "can_update": { "type": "boolean" } }, "additionalProperties": false }, diff --git a/spec/frontend/batch_comments/components/submit_dropdown_spec.js b/spec/frontend/batch_comments/components/submit_dropdown_spec.js index a2591635cf9..2f057af8a7d 100644 --- a/spec/frontend/batch_comments/components/submit_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/submit_dropdown_spec.js @@ -1,13 +1,18 @@ import { GlDisclosureDropdown } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import SubmitDropdown from '~/batch_comments/components/submit_dropdown.vue'; import { mockTracking } from 'helpers/tracking_helper'; +import userCanApproveQuery from '~/batch_comments/queries/can_approve.query.graphql'; jest.mock('~/autosave'); +Vue.use(VueApollo); Vue.use(Vuex); let wrapper; @@ -17,6 +22,26 @@ let trackingSpy; function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) { publishReview = jest.fn(); trackingSpy = mockTracking(undefined, null, jest.spyOn); + const requestHandlers = [ + [ + userCanApproveQuery, + () => + Promise.resolve({ + data: { + project: { + id: 1, + mergeRequest: { + id: 1, + userPermissions: { + canApprove, + }, + }, + }, + }, + }), + ], + ]; + const apolloProvider = createMockApollo(requestHandlers); const store = new Vuex.Store({ getters: { @@ -27,12 +52,17 @@ function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) getNoteableData: () => ({ id: 1, preview_note_path: '/preview', - current_user: { can_approve: canApprove }, }), noteableType: () => 'merge_request', getCurrentUserLastNote: () => ({ id: 1 }), }, modules: { + diffs: { + namespaced: true, + state: { + projectPath: 'gitlab-org/gitlab', + }, + }, batchComments: { namespaced: true, state: { shouldAnimateReviewButton }, @@ -44,6 +74,7 @@ function factory({ canApprove = true, shouldAnimateReviewButton = false } = {}) }); wrapper = mountExtended(SubmitDropdown, { store, + apolloProvider, }); } @@ -113,11 +144,18 @@ describe('Batch comments submit dropdown', () => { canApprove | exists | existsText ${true} | ${true} | ${'shows'} ${false} | ${false} | ${'hides'} - `('$existsText approve checkbox if can_approve is $canApprove', ({ canApprove, exists }) => { - factory({ canApprove }); + `( + '$existsText approve checkbox if can_approve is $canApprove', + async ({ canApprove, exists }) => { + factory({ canApprove }); - expect(wrapper.findByTestId('approve_merge_request').exists()).toBe(exists); - }); + wrapper.findComponent(GlDisclosureDropdown).vm.$emit('shown'); + + await waitForPromises(); + + expect(wrapper.findByTestId('approve_merge_request').exists()).toBe(exists); + }, + ); it.each` shouldAnimateReviewButton | animationClassApplied | classText diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js index a98e79c69fe..c3f22749978 100644 --- a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js +++ b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js @@ -19,6 +19,10 @@ describe('graph component', () => { const findLinksLayer = () => wrapper.findComponent(LinksLayer); const findStageColumns = () => wrapper.findAllComponents(StageColumnComponent); const findStageNameInJob = () => wrapper.findByTestId('stage-name-in-job'); + const findPipelineContainer = () => wrapper.findByTestId('pipeline-container'); + const findRootGraphLayout = () => wrapper.findByTestId('stage-column'); + const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); + const findJobItem = () => wrapper.findComponent(JobItem); const defaultProps = { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), @@ -42,6 +46,9 @@ describe('graph component', () => { mountFn = shallowMount, props = {}, stubOverride = {}, + glFeatures = { + newPipelineGraph: false, + }, } = {}) => { wrapper = mountFn(PipelineGraph, { propsData: { @@ -61,6 +68,9 @@ describe('graph component', () => { 'job-group-dropdown': true, ...stubOverride, }, + provide: { + glFeatures, + }, }); }; @@ -112,9 +122,8 @@ describe('graph component', () => { }); it('dims unrelated jobs', () => { - const unrelatedJob = wrapper.findComponent(JobItem); expect(findLinksLayer().emitted().highlightedJobsChange).toHaveLength(1); - expect(unrelatedJob.classes('gl-opacity-3')).toBe(true); + expect(findJobItem().classes('gl-opacity-3')).toBe(true); }); }); }); @@ -179,4 +188,82 @@ describe('graph component', () => { expect(findDownstreamColumn().props().linkedPipelines).toHaveLength(1); }); }); + + describe.each` + name | value | state + ${'disabled'} | ${false} | ${'should not'} + ${'enabled'} | ${true} | ${'should'} + `('With feature flag newPipelineGraph $name', ({ value, state }) => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + stubOverride: { 'job-item': false, StageColumnComponent }, + glFeatures: { + newPipelineGraph: value, + }, + stubs: { + StageColumnComponent, + }, + }); + }); + + it(`${state} add class pipeline-graph-container on wrapper`, () => { + expect(findPipelineContainer().classes('pipeline-graph-container')).toBe(value); + }); + + it(`${state} add class is-stage-view on rootGraphLayout`, () => { + expect(findRootGraphLayout().classes('is-stage-view')).toBe(value); + }); + + it(`${state} add titleClasses on stageColumnTitle`, () => { + const titleClasses = [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-4', + 'gl-mb-n2', + ]; + const legacyTitleClasses = [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-3', + ]; + const checkClasses = value ? titleClasses : legacyTitleClasses; + + expect(findStageColumnTitle().classes()).toEqual(expect.arrayContaining(checkClasses)); + }); + + it(`${state} add jobClasses on findJobItem`, () => { + const jobClasses = [ + 'gl-p-3', + 'gl-border-0', + 'gl-bg-transparent', + 'gl-rounded-base', + 'gl-hover-bg-gray-50', + 'gl-focus-bg-gray-50', + 'gl-hover-text-gray-900', + 'gl-focus-text-gray-900', + ]; + const legacyJobClasses = [ + 'gl-p-3', + 'gl-border-gray-100', + 'gl-border-solid', + 'gl-border-1', + 'gl-bg-white', + 'gl-rounded-7', + 'gl-hover-bg-gray-50', + 'gl-focus-bg-gray-50', + 'gl-hover-text-gray-900', + 'gl-focus-text-gray-900', + 'gl-hover-border-gray-200', + 'gl-focus-border-gray-200', + ]; + const checkClasses = value ? jobClasses : legacyJobClasses; + + expect(findJobItem().props('cssClassJobName')).toEqual(expect.arrayContaining(checkClasses)); + }); + }); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 47fd96ff625..5299361a493 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -22,11 +22,14 @@ import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vu import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; import eventHub from '~/diffs/event_hub'; +import { EVT_DISCUSSIONS_ASSIGNED } from '~/diffs/constants'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { Mousetrap } from '~/lib/mousetrap'; import * as urlUtils from '~/lib/utils/url_utility'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { stubPerformanceWebAPI } from 'helpers/performance'; import createDiffsStore from '../create_diffs_store'; import diffsMockData from '../mock_data/merge_request_diffs'; @@ -662,6 +665,12 @@ describe('diffs/components/app', () => { }); describe('file-by-file', () => { + let hashSpy; + + beforeEach(() => { + hashSpy = jest.spyOn(commonUtils, 'handleLocationHash'); + }); + it('renders a single diff', async () => { createComponent( undefined, @@ -681,6 +690,48 @@ describe('diffs/components/app', () => { expect(wrapper.findAllComponents(DiffFile).length).toBe(1); }); + describe('rechecking the url hash for scrolling', () => { + const advanceAndCheckCalls = (count = 0) => { + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + expect(hashSpy).toHaveBeenCalledTimes(count); + }; + + it('re-checks one time after the file finishes loading', () => { + createComponent( + undefined, + ({ state }) => { + state.diffs.diffFiles = [{ isLoadingFullFile: true }]; + }, + undefined, + { viewDiffsFileByFile: true }, + ); + + // The hash check is not called if the file is still marked as loading + expect(hashSpy).toHaveBeenCalledTimes(0); + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + advanceAndCheckCalls(); + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + advanceAndCheckCalls(); + // Once the file has finished loading, it calls through to check the hash + store.state.diffs.diffFiles[0].isLoadingFullFile = false; + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + advanceAndCheckCalls(1); + // No further scrolls happen after one hash check / scroll + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + advanceAndCheckCalls(1); + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + advanceAndCheckCalls(1); + }); + + it('does not re-check when not in single-file mode', () => { + createComponent(); + + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); + + expect(hashSpy).not.toHaveBeenCalled(); + }); + }); + describe('pagination', () => { const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]'); const paginator = () => fileByFileNav().findComponent(GlPagination); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 13efd3584b4..34af3d72b04 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -13,6 +13,7 @@ import DiffFileComponent from '~/diffs/components/diff_file.vue'; import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue'; import { + EVT_DISCUSSIONS_ASSIGNED, EVT_EXPAND_ALL_FILES, EVT_PERF_MARK_DIFF_FILES_END, EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, @@ -271,9 +272,10 @@ describe('DiffFile', () => { await nextTick(); // Wait for the idleCallback await nextTick(); // Wait for nextTick inside postRender - expect(eventHub.$emit).toHaveBeenCalledTimes(2); + expect(eventHub.$emit).toHaveBeenCalledTimes(3); expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN); expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END); + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_DISCUSSIONS_ASSIGNED); }); }); }); diff --git a/spec/frontend/search/sidebar/components/blobs_filters_spec.js b/spec/frontend/search/sidebar/components/blobs_filters_spec.js index 729fae44c19..245ddb8f8bb 100644 --- a/spec/frontend/search/sidebar/components/blobs_filters_spec.js +++ b/spec/frontend/search/sidebar/components/blobs_filters_spec.js @@ -17,7 +17,7 @@ describe('GlobalSearch BlobsFilters', () => { currentScope: () => 'blobs', }; - const createComponent = ({ initialState = {}, searchBlobsHideArchivedProjects = true } = {}) => { + const createComponent = ({ initialState = {} } = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, @@ -30,11 +30,6 @@ describe('GlobalSearch BlobsFilters', () => { wrapper = shallowMount(BlobsFilters, { store, - provide: { - glFeatures: { - searchBlobsHideArchivedProjects, - }, - }, }); }; @@ -42,29 +37,20 @@ describe('GlobalSearch BlobsFilters', () => { const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); const findDividers = () => wrapper.findAll('hr'); - describe.each` - description | searchBlobsHideArchivedProjects - ${'Renders correctly with Archived Filter enabled'} | ${true} - ${'Renders correctly with Archived Filter disabled'} | ${false} - `('$description', ({ searchBlobsHideArchivedProjects }) => { - beforeEach(() => { - createComponent({ - searchBlobsHideArchivedProjects, - }); - }); + beforeEach(() => { + createComponent({}); + }); - it('renders LanguageFilter', () => { - expect(findLanguageFilter().exists()).toBe(true); - }); + it('renders LanguageFilter', () => { + expect(findLanguageFilter().exists()).toBe(true); + }); - it(`renders correctly ArchivedFilter when searchBlobsHideArchivedProjects is ${searchBlobsHideArchivedProjects}`, () => { - expect(findArchivedFilter().exists()).toBe(searchBlobsHideArchivedProjects); - }); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); - it('renders divider correctly', () => { - const dividersCount = searchBlobsHideArchivedProjects ? 1 : 0; - expect(findDividers()).toHaveLength(dividersCount); - }); + it('renders divider correctly', () => { + expect(findDividers()).toHaveLength(1); }); describe('Renders correctly in new nav', () => { @@ -74,7 +60,6 @@ describe('GlobalSearch BlobsFilters', () => { searchType: SEARCH_TYPE_ADVANCED, useSidebarNavigation: true, }, - searchBlobsHideArchivedProjects: true, }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js new file mode 100644 index 00000000000..3ec791d421f --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/unresolved_discussions_spec.js @@ -0,0 +1,39 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import notesEventHub from '~/notes/event_hub'; +import MergeChecksUnresolvedDiscussions from '~/vue_merge_request_widget/components/checks/unresolved_discussions.vue'; +import MergeChecksMessage from '~/vue_merge_request_widget/components/checks/message.vue'; + +describe('MergeChecksUnresolvedDiscussions component', () => { + let wrapper; + + function createComponent( + propsData = { check: { result: 'failed', failureReason: 'Failed message' } }, + ) { + wrapper = mountExtended(MergeChecksUnresolvedDiscussions, { + propsData, + }); + } + + it('passes check down to the MergeChecksMessage', () => { + const check = { result: 'failed', failureReason: 'Unresolved discussions' }; + createComponent({ check }); + + expect(wrapper.findComponent(MergeChecksMessage).props('check')).toEqual(check); + }); + + it('does not show go to first unresolved discussion button with passed state', () => { + createComponent({ check: { result: 'passed' } }); + const button = wrapper.findByRole('button', { name: 'Go to first unresolved thread' }); + expect(button.exists()).toBe(false); + }); + + it('triggers go to first discussion action', () => { + const callback = jest.fn(); + notesEventHub.$on('jumpToFirstUnresolvedDiscussion', callback); + createComponent(); + + wrapper.findByRole('button', { name: 'Go to first unresolved thread' }).trigger('click'); + + expect(callback).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js index 6224d6e42ee..f8a09391e5d 100644 --- a/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/merge_checks_spec.js @@ -1,18 +1,22 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import MergeChecksComponent from '~/vue_merge_request_widget/components/merge_checks.vue'; import mergeChecksQuery from '~/vue_merge_request_widget/queries/merge_checks.query.graphql'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; +import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; +import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants'; +import conflictsStateQuery from '~/vue_merge_request_widget/queries/states/conflicts.query.graphql'; +import rebaseStateQuery from '~/vue_merge_request_widget/queries/states/rebase.query.graphql'; Vue.use(VueApollo); let wrapper; let apolloProvider; -function factory({ canMerge = true, mergeChecks = [] } = {}) { +function factory(mountFn, { canMerge = true, mergeChecks = [] } = {}) { apolloProvider = createMockApollo([ [ mergeChecksQuery, @@ -25,9 +29,56 @@ function factory({ canMerge = true, mergeChecks = [] } = {}) { }, }), ], + [ + conflictsStateQuery, + () => + Promise.resolve({ + data: { + project: { + id: 1, + mergeRequest: { + id: 1, + shouldBeRebased: false, + sourceBranchProtected: false, + userPermissions: { pushToSourceBranch: true }, + }, + }, + }, + }), + ], + [ + rebaseStateQuery, + () => + Promise.resolve({ + data: { + project: { + id: '1', + mergeRequest: { + id: '2', + rebaseInProgress: false, + targetBranch: 'main', + userPermissions: { + pushToSourceBranch: true, + }, + pipelines: { + nodes: [ + { + id: '1', + project: { + id: '2', + fullPath: 'gitlab/gitlab', + }, + }, + ], + }, + }, + }, + }, + }), + ], ]); - wrapper = mountExtended(MergeChecksComponent, { + wrapper = mountFn(MergeChecksComponent, { apolloProvider, propsData: { mr: {}, @@ -36,13 +87,16 @@ function factory({ canMerge = true, mergeChecks = [] } = {}) { }); } +const mountComponent = factory.bind(null, mountExtended); +const shallowMountComponent = factory.bind(null, shallowMountExtended); + describe('Merge request merge checks component', () => { afterEach(() => { apolloProvider = null; }); it('renders ready to merge text if user can merge', async () => { - factory({ canMerge: true }); + mountComponent({ canMerge: true }); await waitForPromises(); @@ -50,7 +104,7 @@ describe('Merge request merge checks component', () => { }); it('renders ready to merge by members text if user can not merge', async () => { - factory({ canMerge: false }); + mountComponent({ canMerge: false }); await waitForPromises(); @@ -62,7 +116,7 @@ describe('Merge request merge checks component', () => { ${[{ identifier: 'discussions', result: 'failed' }]} | ${'Merge blocked: 1 check failed'} ${[{ identifier: 'discussions', result: 'failed' }, { identifier: 'rebase', result: 'failed' }]} | ${'Merge blocked: 2 checks failed'} `('renders $text for $mergeChecks', async ({ mergeChecks, text }) => { - factory({ mergeChecks }); + mountComponent({ mergeChecks }); await waitForPromises(); @@ -74,15 +128,33 @@ describe('Merge request merge checks component', () => { ${'failed'} | ${'failed'} ${'passed'} | ${'success'} `('renders $statusIcon for $result result', async ({ result, statusIcon }) => { - factory({ mergeChecks: [{ result, identifier: 'discussions' }] }); + mountComponent({ mergeChecks: [{ result, identifier: 'discussions' }] }); await waitForPromises(); expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe(statusIcon); }); + it.each` + identifier + ${'conflicts'} + ${'unresolved_discussions'} + ${'rebase'} + ${'default'} + `('renders $identifier merge check', async ({ identifier }) => { + shallowMountComponent({ mergeChecks: [{ result: 'failed', identifier }] }); + + wrapper.findComponent(StateContainer).vm.$emit('toggle'); + + await waitForPromises(); + + const { default: component } = await COMPONENTS[identifier](); + + expect(wrapper.findComponent(component).exists()).toBe(true); + }); + it('expands collapsed area', async () => { - factory(); + mountComponent(); await waitForPromises(); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js index 16751bcc0f0..213959fe4e2 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/dynamic_content_spec.js @@ -2,6 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; import DynamicContent from '~/vue_merge_request_widget/components/widget/dynamic_content.vue'; import ContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () => { let wrapper; @@ -16,10 +17,13 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () DynamicContent, ContentRow, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; - it('renders given data', () => { + beforeEach(() => { createComponent({ propsData: { data: { @@ -49,10 +53,23 @@ describe('~/vue_merge_request_widget/components/widget/dynamic_content.vue', () text: 'This is recursive. It will be listed in level 3.', }, ], + tooltipText: 'Tooltip text', }, }, }); + }); + it('renders given data', () => { expect(wrapper.html()).toMatchSnapshot(); }); + + it('has a tooltip on the row text', () => { + const text = wrapper.findByText('Main text for the row'); + const tooltip = getBinding(text.element, 'gl-tooltip'); + + expect(tooltip.value).toMatchObject({ + title: 'Tooltip text', + boundary: 'viewport', + }); + }); }); diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index 8f37bf29a4b..4af7fae400e 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -323,34 +323,4 @@ RSpec.describe VisibilityLevelHelper, feature_category: :system_access do it { is_expected.to eq(expected) } end end - - describe '#visibility_level_options' do - let(:user) { build(:user) } - - before do - allow(helper).to receive(:current_user).and_return(user) - end - - it 'returns the desired mapping' do - expected_options = [ - { - level: 0, - label: 'Private', - description: 'The group and its projects can only be viewed by members.' - }, - { - level: 10, - label: 'Internal', - description: 'The group and any internal projects can be viewed by any logged in user except external users.' - }, - { - level: 20, - label: 'Public', - description: 'The group and any public projects can be viewed without any authentication.' - } - ] - - expect(helper.visibility_level_options(group)).to eq expected_options - end - end end diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index d7b69546de6..8396d5469ad 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -157,6 +157,18 @@ RSpec.describe Integration, feature_category: :integrations do include_examples 'hook scope', 'incident' end + describe '.title' do + it 'raises an error' do + expect { described_class.title }.to raise_error(NotImplementedError) + end + end + + describe '.description' do + it 'raises an error' do + expect { described_class.description }.to raise_error(NotImplementedError) + end + end + describe '#operating?' do it 'is false when the integration is not active' do expect(build(:integration).operating?).to eq(false) diff --git a/spec/models/integrations/base_chat_notification_spec.rb b/spec/models/integrations/base_chat_notification_spec.rb index 497f2f1e7c9..9ad37f40fbc 100644 --- a/spec/models/integrations/base_chat_notification_spec.rb +++ b/spec/models/integrations/base_chat_notification_spec.rb @@ -347,12 +347,6 @@ RSpec.describe Integrations::BaseChatNotification, feature_category: :integratio end end - describe '#help' do - it 'raises an error' do - expect { subject.help }.to raise_error(NotImplementedError) - end - end - describe '#event_channel_name' do it 'returns the channel field name for the given event' do expect(subject.event_channel_name(:event)).to eq('event_channel') diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 64e010aa50f..ba3103fd52f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -3688,6 +3688,16 @@ RSpec.describe API::Projects, :aggregate_failures, feature_category: :groups_and it_behaves_like '412 response' do subject(:request) { api("/projects/#{project.id}/share/#{group.id}", user) } end + + it "returns an error when link is not destroyed" do + allow(::Projects::GroupLinks::DestroyService).to receive_message_chain(:new, :execute) + .and_return(ServiceResponse.error(message: '404 Not Found', reason: :not_found)) + + delete api("/projects/#{project.id}/share/#{group.id}", user) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq '404 Not Found' + end end it 'returns a 400 when group id is not an integer' do diff --git a/spec/services/projects/group_links/create_service_spec.rb b/spec/services/projects/group_links/create_service_spec.rb index ca2902af472..e3f170ef3fe 100644 --- a/spec/services/projects/group_links/create_service_spec.rb +++ b/spec/services/projects/group_links/create_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute', feature_category let_it_be(:user) { create :user } let_it_be(:group) { create :group } let_it_be(:project) { create(:project, namespace: create(:namespace, :with_namespace_settings)) } + let_it_be(:group_user) { create(:user).tap { |user| group.add_guest(user) } } let(:opts) do { @@ -37,67 +38,75 @@ RSpec.describe Projects::GroupLinks::CreateService, '#execute', feature_category end end - context 'when user has proper membership to share a group' do + context 'when user has proper permissions to share a project with a group' do before do group.add_guest(user) end - it_behaves_like 'shareable' - - it 'updates authorization', :sidekiq_inline do - expect { subject.execute }.to( - change { Ability.allowed?(user, :read_project, project) } - .from(false).to(true)) - end - - context 'with specialized project_authorization workers' do - let_it_be(:other_user) { create(:user) } - + context 'when the user is a MAINTAINER in the project' do before do - group.add_developer(other_user) + project.add_maintainer(user) end - it 'schedules authorization update for users with access to group' do - stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) - - expect(AuthorizedProjectsWorker).not_to( - receive(:bulk_perform_async) - ) - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to( - receive(:perform_async) - .with(project.id) - .and_call_original - ) - expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( - receive(:bulk_perform_in).with( - 1.hour, - array_including([user.id], [other_user.id]), - batch_delay: 30.seconds, batch_size: 100 - ).and_call_original - ) - - subject.execute + it_behaves_like 'shareable' + + it 'updates authorization', :sidekiq_inline do + expect { subject.execute }.to( + change { Ability.allowed?(group_user, :read_project, project) } + .from(false).to(true)) end - end - context 'when sharing outside the hierarchy is disabled' do - let_it_be(:shared_group_parent) do - create(:group, namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true)) + context 'with specialized project_authorization workers' do + let_it_be(:other_user) { create(:user) } + + before do + group.add_developer(other_user) + end + + it 'schedules authorization update for users with access to group' do + stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) + + expect(AuthorizedProjectsWorker).not_to( + receive(:bulk_perform_async) + ) + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to( + receive(:perform_async) + .with(project.id) + .and_call_original + ) + expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( + receive(:bulk_perform_in).with( + 1.hour, + array_including([user.id], [other_user.id]), + batch_delay: 30.seconds, batch_size: 100 + ).and_call_original + ) + + subject.execute + end end - let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) } + context 'when sharing outside the hierarchy is disabled' do + let_it_be(:shared_group_parent) do + create(:group, + namespace_settings: create(:namespace_settings, prevent_sharing_groups_outside_hierarchy: true) + ) + end + + let_it_be(:project, reload: true) { create(:project, group: shared_group_parent) } - it_behaves_like 'not shareable' + it_behaves_like 'not shareable' - context 'when group is inside hierarchy' do - let(:group) { create(:group, :private, parent: shared_group_parent) } + context 'when group is inside hierarchy' do + let(:group) { create(:group, :private, parent: shared_group_parent) } - it_behaves_like 'shareable' + it_behaves_like 'shareable' + end end end end - context 'when user does not have permissions for the group' do + context 'when user does not have permissions to share the project with a group' do it_behaves_like 'not shareable' end diff --git a/spec/services/projects/group_links/destroy_service_spec.rb b/spec/services/projects/group_links/destroy_service_spec.rb index 103aff8c659..0cd003f6142 100644 --- a/spec/services/projects/group_links/destroy_service_spec.rb +++ b/spec/services/projects/group_links/destroy_service_spec.rb @@ -6,83 +6,120 @@ RSpec.describe Projects::GroupLinks::DestroyService, '#execute', feature_categor let_it_be(:user) { create :user } let_it_be(:project) { create(:project, :private) } let_it_be(:group) { create(:group) } + let_it_be(:group_user) { create(:user).tap { |user| group.add_guest(user) } } - let!(:group_link) { create(:project_group_link, project: project, group: group) } + let(:group_access) { Gitlab::Access::DEVELOPER } + let!(:group_link) { create(:project_group_link, project: project, group: group, group_access: group_access) } subject { described_class.new(project, user) } - it 'removes group from project' do - expect { subject.execute(group_link) }.to change { project.project_group_links.count }.from(1).to(0) - end - - context 'project authorizations refresh' do - before do - group.add_maintainer(user) + shared_examples_for 'removes group from project' do + it 'removes group from project' do + expect { subject.execute(group_link) }.to change { project.reload.project_group_links.count }.from(1).to(0) end + end - it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) - .to receive(:perform_async).with(group_link.project.id) + shared_examples_for 'returns not_found' do + it do + expect do + result = subject.execute(group_link) - subject.execute(group_link) + expect(result[:status]).to eq(:error) + expect(result[:reason]).to eq(:not_found) + end.not_to change { project.reload.project_group_links.count } end + end - it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do - stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) - - expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( - receive(:bulk_perform_in).with( - 1.hour, - [[user.id]], - batch_delay: 30.seconds, batch_size: 100 - ) - ) - - subject.execute(group_link) - end + context 'if group_link is blank' do + let!(:group_link) { nil } - it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do - expect { subject.execute(group_link) }.to( - change { Ability.allowed?(user, :read_project, project) } - .from(true).to(false)) - end + it_behaves_like 'returns not_found' end - it 'returns false if group_link is blank' do - expect { subject.execute(nil) }.not_to change { project.project_group_links.count } + context 'if the user does not have access to destroy the link' do + it_behaves_like 'returns not_found' end - describe 'todos cleanup' do - context 'when project is private' do - it 'triggers todos cleanup' do - expect(TodosDestroyer::ProjectPrivateWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id) - expect(project.private?).to be true - - subject.execute(group_link) + context 'when the user has proper permissions to remove a group-link from a project' do + context 'when the user is a MAINTAINER in the project' do + before do + project.add_maintainer(user) end - end - context 'when project is public or internal' do - shared_examples_for 'removes confidential todos' do - it 'does not trigger todos cleanup' do - expect(TodosDestroyer::ProjectPrivateWorker).not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id) - expect(TodosDestroyer::ConfidentialIssueWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, nil, project.id) - expect(project.private?).to be false + it_behaves_like 'removes group from project' + + context 'project authorizations refresh' do + it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(group_link.project.id) subject.execute(group_link) end - end - context 'when project is public' do - let(:project) { create(:project, :public) } + it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do + stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) - it_behaves_like 'removes confidential todos' + expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( + receive(:bulk_perform_in).with( + 1.hour, + [[group_user.id]], + batch_delay: 30.seconds, batch_size: 100 + ) + ) + + subject.execute(group_link) + end + + it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do + expect { subject.execute(group_link) }.to( + change { Ability.allowed?(group_user, :read_project, project) } + .from(true).to(false)) + end end - context 'when project is internal' do - let(:project) { create(:project, :public) } + describe 'todos cleanup' do + context 'when project is private' do + it 'triggers todos cleanup' do + expect(TodosDestroyer::ProjectPrivateWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id) + expect(project.private?).to be true + + subject.execute(group_link) + end + end + + context 'when project is public or internal' do + shared_examples_for 'removes confidential todos' do + it 'does not trigger todos cleanup' do + expect(TodosDestroyer::ProjectPrivateWorker).not_to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, project.id) + expect(TodosDestroyer::ConfidentialIssueWorker).to receive(:perform_in).with(Todo::WAIT_FOR_DELETE, nil, project.id) + expect(project.private?).to be false + + subject.execute(group_link) + end + end + + context 'when project is public' do + let(:project) { create(:project, :public) } + + it_behaves_like 'removes confidential todos' + end + + context 'when project is internal' do + let(:project) { create(:project, :public) } + + it_behaves_like 'removes confidential todos' + end + end + end + end + end - it_behaves_like 'removes confidential todos' + context 'when skipping authorization' do + context 'without providing a user' do + it 'destroys the link' do + expect do + described_class.new(project, nil).execute(group_link, skip_authorization: true) + end.to change { project.reload.project_group_links.count }.by(-1) end end end diff --git a/spec/services/projects/group_links/update_service_spec.rb b/spec/services/projects/group_links/update_service_spec.rb index f7607deef04..b02614fa062 100644 --- a/spec/services/projects/group_links/update_service_spec.rb +++ b/spec/services/projects/group_links/update_service_spec.rb @@ -6,8 +6,11 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute', feature_category let_it_be(:user) { create :user } let_it_be(:group) { create :group } let_it_be(:project) { create :project } + let_it_be(:group_user) { create(:user).tap { |user| group.add_developer(user) } } - let!(:link) { create(:project_group_link, project: project, group: group) } + let(:group_access) { Gitlab::Access::DEVELOPER } + + let!(:link) { create(:project_group_link, project: project, group: group, group_access: group_access) } let(:expiry_date) { 1.month.from_now.to_date } let(:group_link_params) do @@ -17,60 +20,78 @@ RSpec.describe Projects::GroupLinks::UpdateService, '#execute', feature_category subject { described_class.new(link, user).execute(group_link_params) } - before do - group.add_developer(user) - end - - it 'updates existing link' do - expect(link.group_access).to eq(Gitlab::Access::DEVELOPER) - expect(link.expires_at).to be_nil - - subject - - link.reload + shared_examples_for 'returns not_found' do + it do + result = subject - expect(link.group_access).to eq(Gitlab::Access::GUEST) - expect(link.expires_at).to eq(expiry_date) - end - - context 'project authorizations update' do - it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) - .to receive(:perform_async).with(link.project.id) - - subject - end - - it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker with a delay to update project authorizations' do - stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) - - expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( - receive(:bulk_perform_in).with( - 1.hour, - [[user.id]], - batch_delay: 30.seconds, batch_size: 100 - ) - ) - - subject - end - - it 'updates project authorizations of users who had access to the project via the group share', :sidekiq_inline do - group.add_maintainer(user) - - expect { subject }.to( - change { Ability.allowed?(user, :create_release, project) } - .from(true).to(false)) + expect(result[:status]).to eq(:error) + expect(result[:reason]).to eq(:not_found) end end - context 'with only param not requiring authorization refresh' do - let(:group_link_params) { { expires_at: Date.tomorrow } } - - it 'does not perform any project authorizations update using `AuthorizedProjectUpdate::ProjectRecalculateWorker`' do - expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async) + context 'when the user does not have proper permissions to update a project group link' do + it_behaves_like 'returns not_found' + end - subject + context 'when user has proper permissions to update a project group link' do + context 'when the user is a MAINTAINER in the project' do + before do + project.add_maintainer(user) + end + + it 'updates existing link' do + expect(link.group_access).to eq(Gitlab::Access::DEVELOPER) + expect(link.expires_at).to be_nil + + subject + + link.reload + + expect(link.group_access).to eq(Gitlab::Access::GUEST) + expect(link.expires_at).to eq(expiry_date) + end + + context 'project authorizations update' do + it 'calls AuthorizedProjectUpdate::ProjectRecalculateWorker to update project authorizations' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker) + .to receive(:perform_async).with(link.project.id) + + subject + end + + it 'calls AuthorizedProjectUpdate::UserRefreshFromReplicaWorker ' \ + 'with a delay to update project authorizations' do + stub_feature_flags(do_not_run_safety_net_auth_refresh_jobs: false) + + expect(AuthorizedProjectUpdate::UserRefreshFromReplicaWorker).to( + receive(:bulk_perform_in).with( + 1.hour, + [[group_user.id]], + batch_delay: 30.seconds, batch_size: 100 + ) + ) + + subject + end + + it 'updates project authorizations of users who had access to the project via the group share', + :sidekiq_inline do + expect { subject }.to( + change { Ability.allowed?(group_user, :developer_access, project) } + .from(true).to(false)) + end + end + + context 'with only param not requiring authorization refresh' do + let(:group_link_params) { { expires_at: Date.tomorrow } } + + it 'does not perform any project authorizations update using ' \ + '`AuthorizedProjectUpdate::ProjectRecalculateWorker`' do + expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).not_to receive(:perform_async) + + subject + end + end end end end |