diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-25 15:18:56 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-25 15:18:56 +0300 |
commit | d2d913b606702ecefa01f03362602fde256e3f75 (patch) | |
tree | 07643306ee63f789188a9133823aac3c92c94dfb /spec | |
parent | af69e63b6655a450849a8fa2640ae6ce5a8db681 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
59 files changed, 1186 insertions, 360 deletions
diff --git a/spec/factories/bulk_import/export_batches.rb b/spec/factories/bulk_import/export_batches.rb index 4339b02d27e..f5f12696f5f 100644 --- a/spec/factories/bulk_import/export_batches.rb +++ b/spec/factories/bulk_import/export_batches.rb @@ -7,6 +7,7 @@ FactoryBot.define do upload { association(:bulk_import_export_upload) } status { 0 } + batch_number { 1 } trait :started do status { 0 } diff --git a/spec/factories/bulk_import/exports.rb b/spec/factories/bulk_import/exports.rb index dd8831ce33a..795a9bbfe20 100644 --- a/spec/factories/bulk_import/exports.rb +++ b/spec/factories/bulk_import/exports.rb @@ -20,5 +20,9 @@ FactoryBot.define do trait :failed do status { -1 } end + + trait :batched do + batched { true } + end end end diff --git a/spec/factories/ci/processable.rb b/spec/factories/ci/processable.rb index 49e66368f94..49756433713 100644 --- a/spec/factories/ci/processable.rb +++ b/spec/factories/ci/processable.rb @@ -26,13 +26,19 @@ FactoryBot.define do before(:create) do |processable, evaluator| next if processable.ci_stage - if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage) - processable.ci_stage = ci_stage - else - processable.ci_stage = create(:ci_stage, pipeline: processable.pipeline, - project: processable.project || evaluator.project, - name: evaluator.stage, position: evaluator.stage_idx, status: 'created') - end + processable.ci_stage = + if ci_stage = processable.pipeline.stages.find_by(name: evaluator.stage) + ci_stage + else + create( + :ci_stage, + pipeline: processable.pipeline, + project: processable.project || evaluator.project, + name: evaluator.stage, + position: evaluator.stage_idx, + status: 'created' + ) + end end trait :waiting_for_resource do diff --git a/spec/factories/draft_note.rb b/spec/factories/draft_note.rb index cde8831f169..8433271a3c5 100644 --- a/spec/factories/draft_note.rb +++ b/spec/factories/draft_note.rb @@ -28,9 +28,7 @@ FactoryBot.define do end position do - association(:image_diff_position, - file: path, - diff_refs: diff_refs) + association(:image_diff_position, file: path, diff_refs: diff_refs) end end end diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb index 34843dab0fe..2df9f482bb9 100644 --- a/spec/factories/environments.rb +++ b/spec/factories/environments.rb @@ -46,20 +46,19 @@ FactoryBot.define do after(:create) do |environment, evaluator| pipeline = create(:ci_pipeline, project: environment.project) - deployable = create(:ci_build, :success, name: "#{environment.name}:deploy", - pipeline: pipeline) - - deployment = create(:deployment, - :success, - environment: environment, - project: environment.project, - deployable: deployable, - ref: evaluator.ref, - sha: environment.project.commit(evaluator.ref).id) - - teardown_build = create(:ci_build, :manual, - name: "#{environment.name}:teardown", - pipeline: pipeline) + deployable = create(:ci_build, :success, name: "#{environment.name}:deploy", pipeline: pipeline) + + deployment = create( + :deployment, + :success, + environment: environment, + project: environment.project, + deployable: deployable, + ref: evaluator.ref, + sha: environment.project.commit(evaluator.ref).id + ) + + teardown_build = create(:ci_build, :manual, name: "#{environment.name}:teardown", pipeline: pipeline) deployment.update_column(:on_stop, teardown_build.name) environment.update_attribute(:deployments, [deployment]) diff --git a/spec/factories/group_members.rb b/spec/factories/group_members.rb index c8ee52019a4..e1841745cb4 100644 --- a/spec/factories/group_members.rb +++ b/spec/factories/group_members.rb @@ -60,10 +60,12 @@ FactoryBot.define do after(:build) do |group_member, evaluator| if evaluator.tasks_to_be_done.present? - build(:member_task, - member: group_member, - project: build(:project, namespace: group_member.source), - tasks_to_be_done: evaluator.tasks_to_be_done) + build( + :member_task, + member: group_member, + project: build(:project, namespace: group_member.source), + tasks_to_be_done: evaluator.tasks_to_be_done + ) end end end diff --git a/spec/factories/ml/candidates.rb b/spec/factories/ml/candidates.rb index 9d049987cfd..b9a2320138a 100644 --- a/spec/factories/ml/candidates.rb +++ b/spec/factories/ml/candidates.rb @@ -21,10 +21,12 @@ FactoryBot.define do trait :with_artifact do after(:create) do |candidate| - candidate.package = FactoryBot.create(:generic_package, - name: candidate.package_name, - version: candidate.package_version, - project: candidate.project) + candidate.package = FactoryBot.create( + :generic_package, + name: candidate.package_name, + version: candidate.package_version, + project: candidate.project + ) end end end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index c58e7bb2e79..b1e7866f9ce 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -55,28 +55,34 @@ FactoryBot.define do end position do - association(:text_diff_position, - file: "files/ruby/popen.rb", - old_line: nil, - new_line: line_number, - diff_refs: diff_refs) + association( + :text_diff_position, + file: "files/ruby/popen.rb", + old_line: nil, + new_line: line_number, + diff_refs: diff_refs + ) end trait :folded_position do position do - association(:text_diff_position, - file: "files/ruby/popen.rb", - old_line: 1, - new_line: 1, - diff_refs: diff_refs) + association( + :text_diff_position, + file: "files/ruby/popen.rb", + old_line: 1, + new_line: 1, + diff_refs: diff_refs + ) end end factory :image_diff_note_on_merge_request do position do - association(:image_diff_position, - file: "files/images/any_image.png", - diff_refs: diff_refs) + association( + :image_diff_position, + file: "files/images/any_image.png", + diff_refs: diff_refs + ) end end end @@ -101,9 +107,11 @@ FactoryBot.define do factory :diff_note_on_design, parent: :note, traits: [:on_design], class: 'DiffNote' do position do - association(:image_diff_position, - file: noteable.full_path, - diff_refs: noteable.diff_refs) + association( + :image_diff_position, + file: noteable.full_path, + diff_refs: noteable.diff_refs + ) end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 5dc59cfa841..5e6ec007569 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching, sign_in(user) end - it 'reflects dashboard issues count' do + it 'reflects dashboard issues count', :js do visit issues_path expect_counters('issues', '1', n_("%d assigned issue", "%d assigned issues", 1) % 1) diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index ee1e704c6c4..e67e04ee0b0 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -6,43 +6,55 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni include Features::SortingHelpers include FilteredSearchHelpers - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:milestone) { create(:milestone, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:milestone) { create(:milestone, project: project) } - let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) } - let!(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) } + let_it_be(:issue) { create(:issue, project: project, author: user, assignees: [user]) } + let_it_be(:issue2) { create(:issue, project: project, author: user, assignees: [user], milestone: milestone) } + let_it_be(:label) { create(:label, project: project, title: 'bug') } + let_it_be(:label_link) { create(:label_link, label: label, target: issue) } + + let_it_be(:project2) { create(:project, namespace: user.namespace) } + let_it_be(:label2) { create(:label, title: 'bug') } before do + project.labels << label + project2.labels << label2 project.add_maintainer(user) sign_in(user) - - visit_issues end context 'without any filter' do it 'shows error message' do + visit issues_dashboard_path + expect(page).to have_content 'Please select at least one filter to see results' end end context 'filtering by milestone' do it 'shows all issues with no milestone' do - input_filtered_search("milestone:=none") + visit issues_dashboard_path + + select_tokens 'Milestone', '=', 'None', submit: true expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end it 'shows all issues with the selected milestone' do - input_filtered_search("milestone:=%\"#{milestone.title}\"") + visit issues_dashboard_path + + select_tokens 'Milestone', '=', milestone.title, submit: true expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_selector('.issue', count: 1) end it 'updates atom feed link' do - visit_issues(milestone_title: '', assignee_username: user.username) + visit issues_dashboard_path(milestone_title: '', assignee_username: user.username) + click_button 'Actions' link = find_link('Subscribe to RSS feed') params = CGI.parse(URI.parse(link[:href]).query) @@ -59,40 +71,47 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni end context 'filtering by label' do - let(:label) { create(:label, project: project) } - let!(:label_link) { create(:label_link, label: label, target: issue) } + before do + visit issues_dashboard_path + end it 'shows all issues with the selected label' do - input_filtered_search("label:=~#{label.title}") + select_tokens 'Label', '=', label.title, submit: true - page.within 'ul.content-list' do - expect(page).to have_content issue.title - expect(page).not_to have_content issue2.title - end + expect(page).to have_content issue.title + expect(page).not_to have_content issue2.title + end + + it 'removes duplicate labels' do + select_tokens 'Label', '=' + send_keys 'bu' + + expect_suggestion('bug') + expect_suggestion_count(3) # Expect None, Any, and bug end end context 'sorting' do before do - visit_issues(assignee_username: user.username) + visit issues_dashboard_path(assignee_username: user.username) end it 'remembers last sorting value' do - pajamas_sort_by(s_('SortOptions|Created date')) - visit_issues(assignee_username: user.username) + click_button 'Created date' + click_button 'Updated date' + + visit issues_dashboard_path(assignee_username: user.username) - expect(page).to have_button('Created date') + expect(page).to have_button('Updated date') end it 'keeps sorting issues after visiting Projects Issues page' do - pajamas_sort_by(s_('SortOptions|Created date')) + click_button 'Created date' + click_button 'Due date' + visit project_issues_path(project) - expect(page).to have_button('Created date') + expect(page).to have_button('Due date') end end - - def visit_issues(...) - visit issues_dashboard_path(...) - end end diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 4499aa021ff..70d9f7e5137 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -5,15 +5,15 @@ require 'spec_helper' RSpec.describe 'Dashboard Issues', feature_category: :team_planning do include FilteredSearchHelpers - let(:current_user) { create :user } - let(:user) { current_user } # Shared examples depend on this being available - let!(:public_project) { create(:project, :public) } - let(:project) { create(:project) } - let(:project_with_issues_disabled) { create(:project, :issues_disabled) } - let!(:authored_issue) { create :issue, author: current_user, project: project } - let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } - let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } - let!(:other_issue) { create :issue, project: project } + let_it_be(:current_user) { create :user } + let_it_be(:user) { current_user } # Shared examples depend on this being available + let_it_be(:public_project) { create(:project, :public) } + let_it_be(:project) { create(:project) } + let_it_be(:project_with_issues_disabled) { create(:project, :issues_disabled) } + let_it_be(:authored_issue) { create :issue, author: current_user, project: project } + let_it_be(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } + let_it_be(:assigned_issue) { create :issue, assignees: [current_user], project: project } + let_it_be(:other_issue) { create :issue, project: project } before do [project, project_with_issues_disabled].each { |project| project.add_maintainer(current_user) } @@ -23,16 +23,16 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :issues_dashboard_path, :issues - describe 'issues' do + describe 'issues', :js do it 'shows issues assigned to current user' do expect(page).to have_content(assigned_issue.title) expect(page).not_to have_content(authored_issue.title) expect(page).not_to have_content(other_issue.title) end - it 'shows issues when current user is author', :js do - reset_filters - input_filtered_search("author:=#{current_user.to_reference}") + it 'shows issues when current user is author' do + click_button 'Clear' + select_tokens 'Author', '=', current_user.to_reference, submit: true expect(page).to have_content(authored_issue.title) expect(page).to have_content(authored_issue_on_public_project.title) @@ -41,12 +41,21 @@ RSpec.describe 'Dashboard Issues', feature_category: :team_planning do end it 'state filter tabs work' do - find('#state-closed').click - expect(page).to have_current_path(issues_dashboard_url(assignee_username: current_user.username, state: 'closed'), url: true) + click_link 'Closed' + + expect(page).not_to have_content(assigned_issue.title) + expect(page).not_to have_content(authored_issue.title) + expect(page).not_to have_content(other_issue.title) end - it_behaves_like "it has an RSS button with current_user's feed token" - it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + describe 'RSS link' do + before do + click_button 'Actions' + end + + it_behaves_like "it has an RSS link with current_user's feed token" + it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" + end end describe 'new issue dropdown' do diff --git a/spec/features/dashboard/label_filter_spec.rb b/spec/features/dashboard/label_filter_spec.rb deleted file mode 100644 index f116c84ff40..00000000000 --- a/spec/features/dashboard/label_filter_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Dashboard > label filter', :js, feature_category: :team_planning do - include FilteredSearchHelpers - - let(:filtered_search) { find('.filtered-search') } - let(:filter_dropdown) { find("#js-dropdown-label .filter-dropdown") } - - let(:user) { create(:user) } - let(:project) { create(:project, name: 'test', namespace: user.namespace) } - let(:project2) { create(:project, name: 'test2', path: 'test2', namespace: user.namespace) } - let(:label) { create(:label, title: 'bug', color: '#ff0000') } - let(:label2) { create(:label, title: 'bug') } - - before do - project.labels << label - project2.labels << label2 - - sign_in(user) - visit issues_dashboard_path - - init_label_search - end - - context 'duplicate labels' do - it 'removes duplicate labels' do - filtered_search.send_keys('bu') - - expect(filter_dropdown).to have_selector('.filter-dropdown-item', text: 'bug', count: 1) - end - end -end diff --git a/spec/features/groups/labels/index_spec.rb b/spec/features/groups/labels/index_spec.rb index ea27fa2c5d9..7b0a38a83db 100644 --- a/spec/features/groups/labels/index_spec.rb +++ b/spec/features/groups/labels/index_spec.rb @@ -24,6 +24,7 @@ RSpec.describe 'Group labels', feature_category: :team_planning do end it 'shows an edit label button', :js do - expect(page).to have_selector('.edit') + click_button 'Label actions dropdown' + expect(page).to have_link('Edit') end end diff --git a/spec/features/merge_requests/filters_generic_behavior_spec.rb b/spec/features/merge_requests/filters_generic_behavior_spec.rb index 197b9fa770d..4dbbde5168b 100644 --- a/spec/features/merge_requests/filters_generic_behavior_spec.rb +++ b/spec/features/merge_requests/filters_generic_behavior_spec.rb @@ -71,7 +71,7 @@ RSpec.describe 'Merge Requests > Filters generic behavior', :js, feature_categor context 'filter dropdown' do it 'filters by label name' do - init_label_search + filtered_search.set('label:=') filtered_search.send_keys('~bug') page.within '.filter-dropdown' do diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index e379aba094c..ddeab3e3b62 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -35,6 +35,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` placeholder="Search or filter commits" searchbuttonattributes="[object Object]" searchinputattributes="[object Object]" + searchtextoptionlabel="Search for this text" value="" /> diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap index c979ee5a1d2..788e80de3f6 100644 --- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap +++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap @@ -21,7 +21,7 @@ exports[`Comment templates list item component renders list item 1`] = ` class="gl-new-dropdown gl-disclosure-dropdown" > <button - aria-controls="base-dropdown-5" + aria-controls="base-dropdown-7" aria-labelledby="actions-toggle-3" class="btn btn-default btn-md gl-button btn-default-tertiary gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret" data-testid="base-dropdown-toggle" @@ -60,7 +60,7 @@ exports[`Comment templates list item component renders list item 1`] = ` <div class="gl-new-dropdown-panel" data-testid="base-dropdown-menu" - id="base-dropdown-5" + id="base-dropdown-7" > <div class="gl-new-dropdown-inner" diff --git a/spec/frontend/issuable/components/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js index 3e23558ceb4..68b41de4730 100644 --- a/spec/frontend/issuable/components/related_issuable_item_spec.js +++ b/spec/frontend/issuable/components/related_issuable_item_spec.js @@ -1,14 +1,18 @@ import { GlIcon, GlLink, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { stubComponent } from 'helpers/stub_component'; import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; import IssueMilestone from '~/issuable/components/issue_milestone.vue'; import IssueAssignees from '~/issuable/components/issue_assignees.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; +import { mockWorkItemCommentNote } from 'jest/work_items/mock_data'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -18,6 +22,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('RelatedIssuableItem', () => { let wrapper; + let showModalSpy; const defaultProps = { idKey: 1, @@ -40,13 +45,25 @@ describe('RelatedIssuableItem', () => { const findRemoveButton = () => wrapper.findComponent(GlButton); const findTitleLink = () => wrapper.findComponent(GlLink); const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); function mountComponent({ data = {}, props = {} } = {}) { + showModalSpy = jest.fn(); wrapper = shallowMount(RelatedIssuableItem, { propsData: { ...defaultProps, ...props, }, + provide: { + reportAbusePath: '/report/abuse/path', + }, + stubs: { + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showModalSpy, + }, + }), + }, data() { return data; }, @@ -265,4 +282,30 @@ describe('RelatedIssuableItem', () => { }); }); }); + + describe('abuse category selector', () => { + beforeEach(() => { + mountComponent({ props: { workItemType: 'TASK' } }); + findTitleLink().vm.$emit('click', { preventDefault: () => {} }); + }); + + it('should not be visible by default', () => { + expect(showModalSpy).toHaveBeenCalled(); + expect(findAbuseCategorySelector().exists()).toBe(false); + }); + + it('should be visible when the work item modal emits `openReportAbuse` event', async () => { + findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(true); + + findAbuseCategorySelector().vm.$emit('close-drawer'); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index b9580b90c12..8807bc311f0 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -22,6 +22,41 @@ describe('RelatedIssuesBlock', () => { const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body'); const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button'); + const createComponent = ({ + mountFn = mountExtended, + pathIdSeparator = PathIdSeparator.Issue, + issuableType = TYPE_ISSUE, + canAdmin = false, + helpPath = '', + isFetching = false, + isFormVisible = false, + relatedIssues = [], + showCategorizedIssues = false, + autoCompleteEpics = true, + slots = '', + } = {}) => { + wrapper = mountFn(RelatedIssuesBlock, { + propsData: { + pathIdSeparator, + issuableType, + canAdmin, + helpPath, + isFetching, + isFormVisible, + relatedIssues, + showCategorizedIssues, + autoCompleteEpics, + }, + provide: { + reportAbusePath: '/report/abuse/path', + }, + stubs: { + GlCard, + }, + slots, + }); + }; + afterEach(() => { if (wrapper) { wrapper.destroy(); @@ -31,12 +66,7 @@ describe('RelatedIssuesBlock', () => { describe('with defaults', () => { beforeEach(() => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: TYPE_ISSUE, - }, - }); + createComponent(); }); it.each` @@ -46,13 +76,11 @@ describe('RelatedIssuesBlock', () => { `( 'displays "$titleText" in the header and "$addButtonText" aria-label for add button when issuableType is set to "$issuableType"', ({ issuableType, pathIdSeparator, titleText, addButtonText }) => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator, - issuableType, - canAdmin: true, - helpPath: '/help/user/project/issues/related_issues', - }, + createComponent({ + pathIdSeparator, + issuableType, + canAdmin: true, + helpPath: '/help/user/project/issues/related_issues', }); expect(wrapper.find('.card-title').text()).toContain(titleText); @@ -73,14 +101,8 @@ describe('RelatedIssuesBlock', () => { it('displays header text slot data', () => { const headerText = '<div>custom header text</div>'; - wrapper = shallowMountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'issue', - }, - stubs: { - GlCard, - }, + createComponent({ + mountFn: shallowMountExtended, slots: { 'header-text': headerText }, }); @@ -92,14 +114,8 @@ describe('RelatedIssuesBlock', () => { it('displays header actions slot data', () => { const headerActions = '<button data-testid="custom-button">custom button</button>'; - wrapper = shallowMountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'issue', - }, - stubs: { - GlCard, - }, + createComponent({ + mountFn: shallowMountExtended, slots: { 'header-actions': headerActions }, }); @@ -109,12 +125,8 @@ describe('RelatedIssuesBlock', () => { describe('with isFetching=true', () => { beforeEach(() => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - isFetching: true, - issuableType: 'issue', - }, + createComponent({ + isFetching: true, }); }); @@ -125,13 +137,7 @@ describe('RelatedIssuesBlock', () => { describe('with canAddRelatedIssues=true', () => { beforeEach(() => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - canAdmin: true, - issuableType: 'issue', - }, - }); + createComponent({ canAdmin: true }); }); it('can add new related issues', () => { @@ -141,14 +147,7 @@ describe('RelatedIssuesBlock', () => { describe('with isFormVisible=true', () => { beforeEach(() => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - isFormVisible: true, - issuableType: 'issue', - autoCompleteEpics: false, - }, - }); + createComponent({ isFormVisible: true, autoCompleteEpics: false }); }); it('shows add related issues form', () => { @@ -164,19 +163,14 @@ describe('RelatedIssuesBlock', () => { const issueList = () => wrapper.findAll('.js-related-issues-token-list-item'); const categorizedHeadings = () => wrapper.findAll('h4'); const headingTextAt = (index) => categorizedHeadings().at(index).text(); - const mountComponent = (showCategorizedIssues) => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - relatedIssues: [issuable1, issuable2, issuable3], - issuableType: 'issue', - showCategorizedIssues, - }, - }); - }; describe('when showCategorizedIssues=true', () => { - beforeEach(() => mountComponent(true)); + beforeEach(() => + createComponent({ + showCategorizedIssues: true, + relatedIssues: [issuable1, issuable2, issuable3], + }), + ); it('should render issue tokens items', () => { expect(issueList()).toHaveLength(3); @@ -203,8 +197,10 @@ describe('RelatedIssuesBlock', () => { describe('when showCategorizedIssues=false', () => { it('should render issues as a flat list with no header', () => { - mountComponent(false); - + createComponent({ + showCategorizedIssues: false, + relatedIssues: [issuable1, issuable2, issuable3], + }); expect(issueList()).toHaveLength(3); expect(categorizedHeadings()).toHaveLength(0); }); @@ -223,14 +219,8 @@ describe('RelatedIssuesBlock', () => { }, ].forEach(({ issuableType, icon }) => { it(`issuableType=${issuableType} is passed`, () => { - wrapper = shallowMountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType, - }, - stubs: { - GlCard, - }, + createComponent({ + issuableType, }); const iconComponent = wrapper.findComponent(GlIcon); @@ -242,15 +232,8 @@ describe('RelatedIssuesBlock', () => { describe('toggle', () => { beforeEach(() => { - wrapper = shallowMountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - relatedIssues: [issuable1, issuable2, issuable3], - issuableType: TYPE_ISSUE, - }, - stubs: { - GlCard, - }, + createComponent({ + relatedIssues: [issuable1, issuable2, issuable3], }); }); @@ -280,14 +263,12 @@ describe('RelatedIssuesBlock', () => { `( 'displays "$emptyText" in the body and "$helpLinkText" aria-label for help link', ({ issuableType, pathIdSeparator, showCategorizedIssues, emptyText, helpLinkText }) => { - wrapper = mountExtended(RelatedIssuesBlock, { - propsData: { - pathIdSeparator, - issuableType, - canAdmin: true, - helpPath: '/help/user/project/issues/related_issues', - showCategorizedIssues, - }, + createComponent({ + pathIdSeparator, + issuableType, + canAdmin: true, + helpPath: '/help/user/project/issues/related_issues', + showCategorizedIssues, }); expect(wrapper.findByTestId('related-issues-body').text()).toContain(emptyText); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js index 9bb71ec3dcb..0a6a0a90d44 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js @@ -13,6 +13,30 @@ import { PathIdSeparator } from '~/related_issues/constants'; describe('RelatedIssuesList', () => { let wrapper; + const createComponent = ({ + mountFn = shallowMount, + pathIdSeparator = PathIdSeparator.Issue, + issuableType = 'issue', + listLinkType = 'relates_to', + heading = '', + isFetching = false, + relatedIssues = [], + } = {}) => { + wrapper = mountFn(RelatedIssuesList, { + propsData: { + pathIdSeparator, + issuableType, + listLinkType, + heading, + isFetching, + relatedIssues, + }, + provide: { + reportAbusePath: '/report/abuse/path', + }, + }); + }; + afterEach(() => { if (wrapper) { wrapper.destroy(); @@ -24,14 +48,7 @@ describe('RelatedIssuesList', () => { const heading = 'Related to'; beforeEach(() => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'issue', - listLinkType: 'relates_to', - heading, - }, - }); + createComponent({ heading }); }); it('assigns value of listLinkType prop to data attribute', () => { @@ -49,13 +66,7 @@ describe('RelatedIssuesList', () => { describe('with isFetching=true', () => { beforeEach(() => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - isFetching: true, - issuableType: 'issue', - }, - }); + createComponent({ isFetching: true }); }); it('should show loading icon', () => { @@ -65,13 +76,7 @@ describe('RelatedIssuesList', () => { describe('methods', () => { beforeEach(() => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5], - issuableType: 'issue', - }, - }); + createComponent({ relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5] }); }); it('updates the order correctly when an item is moved to the top', () => { @@ -112,23 +117,17 @@ describe('RelatedIssuesList', () => { }); describe('issuableOrderingId returns correct issuable order id when', () => { - it('issuableType is epic', () => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'issue', - }, + it('issuableType is issue', () => { + createComponent({ + issuableType: 'issue', }); expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId); }); - it('issuableType is issue', () => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'epic', - }, + it('issuableType is epic', () => { + createComponent({ + issuableType: 'epic', }); expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id); @@ -143,12 +142,9 @@ describe('RelatedIssuesList', () => { }); it('issuableType is epic', () => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'epic', - relatedIssues, - }, + createComponent({ + issuableType: 'epic', + relatedIssues, }); const listItems = wrapper.vm.$el.querySelectorAll('.list-item'); @@ -159,12 +155,9 @@ describe('RelatedIssuesList', () => { }); it('issuableType is issue', () => { - wrapper = shallowMount(RelatedIssuesList, { - propsData: { - pathIdSeparator: PathIdSeparator.Issue, - issuableType: 'issue', - relatedIssues, - }, + createComponent({ + issuableType: 'issue', + relatedIssues, }); const listItems = wrapper.vm.$el.querySelectorAll('.list-item'); @@ -177,13 +170,7 @@ describe('RelatedIssuesList', () => { describe('related item contents', () => { beforeAll(() => { - wrapper = mount(RelatedIssuesList, { - propsData: { - issuableType: 'issue', - pathIdSeparator: PathIdSeparator.Issue, - relatedIssues: [issuable1], - }, - }); + createComponent({ mountFn: mount, relatedIssues: [issuable1] }); }); it('shows due date', () => { diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index 1383013aedb..b119c836411 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -42,6 +42,9 @@ describe('RelatedIssuesRoot', () => { ...defaultProps, ...props, }, + provide: { + reportAbusePath: '/report/abuse/path', + }, data() { return data; }, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index f115ec2d6ca..d87aa3194d2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -99,6 +99,7 @@ function createComponent({ portalName: 'fake target', alignSuggestions: jest.fn(), suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, filteredSearchSuggestionListInstance: { register: jest.fn(), unregister: jest.fn(), diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index a6bb32736db..6bbbfd838a0 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -46,6 +46,7 @@ function createComponent(options = {}) { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index ce134f7d24e..fb8cea09a9b 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -71,6 +71,7 @@ describe('CrmContactToken', () => { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, listeners, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index f41c5b5d432..20369342220 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -70,6 +70,7 @@ describe('CrmOrganizationToken', () => { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, listeners, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index 0dddae50c4e..5e675c10038 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -52,6 +52,7 @@ function createComponent(options = {}) { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 696483df8ef..c55721fe032 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -51,6 +51,7 @@ function createComponent(options = {}) { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, listeners, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index c758e550ba2..db51b4a05b1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -49,6 +49,7 @@ function createComponent(options = {}) { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js index 5190ab919b1..79fd527cbe3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -24,6 +24,7 @@ describe('ReleaseToken', () => { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js index d0a6519f16d..e4ca7dcb19a 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js @@ -57,6 +57,7 @@ function createComponent(options = {}) { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: () => 'custom-class', + termsAsTokens: () => false, }, data() { return { ...data }; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index cd4ebe334c0..015e08ed760 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -1,6 +1,6 @@ import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, ErrorWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -41,7 +41,12 @@ describe('RunnerInstructionsModal component', () => { let runnerPlatformsHandler; const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findAlert = () => wrapper.findComponent(GlAlert); + const findAlert = (variant = 'danger') => { + const { wrappers } = wrapper + .findAllComponents(GlAlert) + .filter((w) => w.props('variant') === variant); + return wrappers[0] || new ErrorWrapper(); + }; const findModal = () => wrapper.findComponent(GlModal); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); @@ -84,6 +89,10 @@ describe('RunnerInstructionsModal component', () => { expect(findAlert().exists()).toBe(false); }); + it('should not show deprecation alert', () => { + expect(findAlert('warning').exists()).toBe(false); + }); + it('should contain a number of platforms buttons', () => { expect(runnerPlatformsHandler).toHaveBeenCalledWith({}); @@ -100,6 +109,21 @@ describe('RunnerInstructionsModal component', () => { ); }); + describe.each` + glFeatures | deprecationAlertExists + ${{}} | ${false} + ${{ createRunnerWorkflowForAdmin: true }} | ${true} + ${{ createRunnerWorkflowForNamespace: true }} | ${true} + `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => { + beforeEach(() => { + createComponent({ provide: { glFeatures } }); + }); + + it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => { + expect(findAlert('warning').exists()).toBe(deprecationAlertExists); + }); + }); + describe('when the modal resizes', () => { it('to an xs viewport', async () => { MockResizeObserver.mockResize('xs'); diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js index b406c9d843a..99bf391e261 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js @@ -22,6 +22,7 @@ describe('Work Item Note Actions', () => { const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]'); const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]'); const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]'); + const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]'); const addEmojiMutationResolver = jest.fn().mockResolvedValue({ data: { @@ -39,6 +40,7 @@ describe('Work Item Note Actions', () => { showEdit = true, showAwardEmoji = true, showAssignUnassign = false, + canReportAbuse = false, } = {}) => { wrapper = shallowMount(WorkItemNoteActions, { propsData: { @@ -47,6 +49,7 @@ describe('Work Item Note Actions', () => { noteId, showAwardEmoji, showAssignUnassign, + canReportAbuse, }, provide: { glFeatures: { @@ -195,4 +198,30 @@ describe('Work Item Note Actions', () => { expect(wrapper.emitted('assignUser')).toEqual([[]]); }); }); + + describe('report abuse to admin', () => { + it('should not report abuse to admin by default', () => { + createComponent(); + + expect(findReportAbuseToAdminButton().exists()).toBe(false); + }); + + it('should display assign/unassign when the props is true', () => { + createComponent({ + canReportAbuse: true, + }); + + expect(findReportAbuseToAdminButton().exists()).toBe(true); + }); + + it('should emit `reportAbuse` event when report abuse action is clicked', () => { + createComponent({ + canReportAbuse: true, + }); + + findReportAbuseToAdminButton().vm.$emit('click'); + + expect(wrapper.emitted('reportAbuse')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js index 69b7c7b0828..f8be2f5667b 100644 --- a/spec/frontend/work_items/components/notes/work_item_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js @@ -300,5 +300,23 @@ describe('Work Item Note', () => { }); }); }); + + describe('report abuse props', () => { + it.each` + currentUserId | canReportAbuse | sameAsAuthor + ${1} | ${false} | ${'same as'} + ${4} | ${true} | ${'not same as'} + `( + 'should be $canReportAbuse when the author is $sameAsAuthor as the author of the note', + ({ currentUserId, canReportAbuse }) => { + window.gon = { + current_user_id: currentUserId, + }; + createComponent(); + + expect(findNoteActions().props('canReportAbuse')).toBe(canReportAbuse); + }, + ); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 46189850e09..1d164648e27 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -26,6 +26,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { i18n } from '~/work_items/constants'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql'; @@ -43,6 +44,7 @@ import { workItemAssigneesSubscriptionResponse, workItemMilestoneSubscriptionResponse, objectiveType, + mockWorkItemCommentNote, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -88,6 +90,7 @@ describe('WorkItemDetail component', () => { const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); const findNotesWidget = () => wrapper.findComponent(WorkItemNotes); const findModal = () => wrapper.findComponent(WorkItemDetailModal); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); const createComponent = ({ isModal = false, @@ -128,6 +131,7 @@ describe('WorkItemDetail component', () => { hasIssuableHealthStatusFeature: true, projectNamespace: 'namespace', fullPath: 'group/project', + reportAbusePath: '/report/abuse/path', }, stubs: { WorkItemWeight: true, @@ -725,4 +729,30 @@ describe('WorkItemDetail component', () => { expect(findCreatedUpdated().exists()).toBe(true); }); + + describe('abuse category selector', () => { + beforeEach(async () => { + setWindowLocation('?work_item_id=2'); + createComponent(); + await waitForPromises(); + }); + + it('should not be visible by default', () => { + expect(findAbuseCategorySelector().exists()).toBe(false); + }); + + it('should be visible when the work item modal emits `openReportAbuse` event', async () => { + findModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(true); + + findAbuseCategorySelector().vm.$emit('close-drawer'); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index efa08ced3ad..4bf7d0c57a3 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -12,6 +12,7 @@ import WidgetWrapper from '~/work_items/components/widget_wrapper.vue'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import { FORM_TYPES } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; @@ -25,6 +26,7 @@ import { changeWorkItemParentMutationResponse, workItemQueryResponse, projectWorkItemResponse, + mockWorkItemCommentNote, } from '../../mock_data'; Vue.use(VueApollo); @@ -76,6 +78,7 @@ describe('WorkItemLinks', () => { provide: { projectPath: 'project/path', hasIterationsFeature, + reportAbusePath: '/report/abuse/path', }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, @@ -105,6 +108,8 @@ describe('WorkItemLinks', () => { const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0); const findAddLinksForm = () => wrapper.findByTestId('add-links-form'); const findChildrenCount = () => wrapper.findByTestId('children-count'); + const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal); + const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector); afterEach(() => { mockApollo = null; @@ -328,7 +333,7 @@ describe('WorkItemLinks', () => { await createComponent(); expect(showModal).not.toHaveBeenCalled(); - expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe(null); + expect(findWorkItemDetailModal().props('workItemIid')).toBe(null); }); it('opens the modal if work item iid URL parameter is found in child items', async () => { @@ -336,6 +341,31 @@ describe('WorkItemLinks', () => { await createComponent(); expect(showModal).toHaveBeenCalled(); - expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe('2'); + expect(findWorkItemDetailModal().props('workItemIid')).toBe('2'); + }); + + describe('abuse category selector', () => { + beforeEach(async () => { + setWindowLocation('?work_item_id=2'); + await createComponent(); + }); + + it('should not be visible by default', () => { + expect(findAbuseCategorySelector().exists()).toBe(false); + }); + + it('should be visible when the work item modal emits `openReportAbuse` event', async () => { + findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(true); + + findAbuseCategorySelector().vm.$emit('close-drawer'); + + await nextTick(); + + expect(findAbuseCategorySelector().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js index 3cc6a9813fc..7dbf828c44a 100644 --- a/spec/frontend/work_items/components/work_item_notes_spec.js +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -97,6 +97,7 @@ describe('WorkItemNotes component', () => { workItemIid = mockWorkItemIid, defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler, deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler, + isModal = false, } = {}) => { wrapper = shallowMount(WorkItemNotes, { apolloProvider: createMockApollo([ @@ -116,6 +117,8 @@ describe('WorkItemNotes component', () => { fullPath: 'test-path', fetchByIid, workItemType: 'task', + reportAbusePath: '/report/abuse/path', + isModal, }, stubs: { GlModal: stubComponent(GlModal, { methods: { show: showModal } }), diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 988fdc301de..86e890ea809 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -74,6 +74,7 @@ describe('Work items router', () => { hasIterationsFeature: false, hasOkrsFeature: false, hasIssuableHealthStatusFeature: false, + reportAbusePath: '/report/abuse/path', }, stubs: { WorkItemWeight: true, diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb index 6c0ac024944..5fbda3d77b0 100644 --- a/spec/helpers/sidebars_helper_spec.rb +++ b/spec/helpers/sidebars_helper_spec.rb @@ -401,7 +401,7 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do it 'returns public links and admin area link' do expect(subject[:context_switcher_links]).to eq([ *public_link, - { title: s_('Navigation|Admin'), link: '/admin', icon: 'admin' } + { title: s_('Navigation|Admin Area'), link: '/admin', icon: 'admin' } ]) end end diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb new file mode 100644 index 00000000000..4e1eca3d411 --- /dev/null +++ b/spec/helpers/work_items_helper_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe WorkItemsHelper, feature_category: :team_planning do + describe '#work_items_index_data' do + subject(:work_items_index_data) { helper.work_items_index_data(project) } + + let_it_be(:project) { build(:project) } + + it 'returns the expected data properties' do + expect(work_items_index_data).to include( + { + full_path: project.full_path, + issues_list_path: project_issues_path(project), + register_path: new_user_registration_path(redirect_to_referer: 'yes'), + sign_in_path: user_session_path(redirect_to_referer: 'yes'), + new_comment_template_path: profile_comment_templates_path, + report_abuse_path: add_category_abuse_reports_path + } + ) + end + end +end diff --git a/spec/initializers/active_record_transaction_observer_spec.rb b/spec/initializers/active_record_transaction_observer_spec.rb new file mode 100644 index 00000000000..a834037dce5 --- /dev/null +++ b/spec/initializers/active_record_transaction_observer_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ActiveRecord Transaction Observer', feature_category: :application_performance do + def load_initializer + load Rails.root.join('config/initializers/active_record_transaction_observer.rb') + end + + context 'when DBMS is available' do + before do + allow_next_instance_of(ActiveRecord::Base.connection) do |connection| # rubocop:disable Database/MultipleDatabases + allow(connection).to receive(:active?).and_return(true) + end + end + + it 'calls Gitlab::Database::Transaction::Observer' do + allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(true) + + expect(Gitlab::Database::Transaction::Observer).to receive(:register!) + + load_initializer + end + + context 'when flipper table does not exist' do + before do + allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError) + end + + it 'does not calls Gitlab::Database::Transaction::Observer' do + expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!) + + load_initializer + end + end + end + + context 'when DBMS is not available' do + before do + allow(ActiveRecord::Base).to receive(:connection).and_raise(PG::ConnectionBad) + end + + it 'does not calls Gitlab::Database::Transaction::Observer' do + expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!) + + load_initializer + end + end +end diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb index 51f21e7f46e..f0a017897c1 100644 --- a/spec/lib/feature_spec.rb +++ b/spec/lib/feature_spec.rb @@ -11,32 +11,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do skip_feature_flags_yaml_validation end - describe '.feature_flags_available?' do - it 'returns false on connection error' do - expect(ActiveRecord::Base.connection).to receive(:active?).and_raise(PG::ConnectionBad) # rubocop:disable Database/MultipleDatabases - - expect(described_class.feature_flags_available?).to eq(false) - end - - it 'returns false when connection is not active' do - expect(ActiveRecord::Base.connection).to receive(:active?).and_return(false) # rubocop:disable Database/MultipleDatabases - - expect(described_class.feature_flags_available?).to eq(false) - end - - it 'returns false when the flipper table does not exist' do - expect(Feature::FlipperFeature).to receive(:table_exists?).and_return(false) - - expect(described_class.feature_flags_available?).to eq(false) - end - - it 'returns false on NoDatabaseError' do - expect(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError) - - expect(described_class.feature_flags_available?).to eq(false) - end - end - describe '.get' do let(:feature) { double(:feature) } let(:key) { 'my_feature' } diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb index 31258c42b5f..8e6aea96c58 100644 --- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb @@ -2,7 +2,8 @@ require 'spec_helper' -RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state do +RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state, + feature_category: :shared do using RSpec::Parameterized::TableSyntax subject(:duplicate_job) do @@ -63,6 +64,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi it_behaves_like 'scheduling with deduplication class', 'None' end end + + # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed + context 'when deduplication strategy is provided in the job options' do + before do + job['deduplicate'] = { 'strategy' => 'until_executed' } + end + + it_behaves_like 'scheduling with deduplication class', 'UntilExecuted' + end end describe '#perform' do @@ -480,6 +490,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi expect(duplicate_job.options).to eq(worker_options) end + + # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed + context 'when deduplication options are provided in the job options' do + it "returns the job's deduplication options" do + job['deduplicate'] = { 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } } + + expect(duplicate_job.options).to eq({ if_deduplicated: :reschedule_once, ttl: 60 }) + end + end end describe '#idempotent?' do diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb index 8660114b719..e50f52c728f 100644 --- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::FileTransfer::GroupConfig do +RSpec.describe BulkImports::FileTransfer::GroupConfig, feature_category: :importers do let_it_be(:exportable) { create(:group) } let_it_be(:hex) { '123' } @@ -49,4 +49,51 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do expect(subject.relation_excluded_keys('group')).to include('owner_id') end end + + describe '#batchable_relation?' do + context 'when relation is batchable' do + it 'returns true' do + expect(subject.batchable_relation?('labels')).to eq(true) + end + end + + context 'when relation is not batchable' do + it 'returns false' do + expect(subject.batchable_relation?('namespace_settings')).to eq(false) + end + end + + context 'when relation is not listed as portable' do + it 'returns false' do + expect(subject.batchable_relation?('foo')).to eq(false) + end + end + end + + describe '#batchable_relations' do + it 'returns a list of collection associations for a group' do + expect(subject.batchable_relations).to include('labels', 'boards', 'milestones') + expect(subject.batchable_relations).not_to include('namespace_settings') + end + end + + describe '#export_service_for' do + context 'when relation is a tree' do + it 'returns TreeExportService' do + expect(subject.export_service_for('labels')).to eq(BulkImports::TreeExportService) + end + end + + context 'when relation is a file' do + it 'returns FileExportService' do + expect(subject.export_service_for('uploads')).to eq(BulkImports::FileExportService) + end + end + + context 'when relation is unknown' do + it 'raises' do + expect { subject.export_service_for('foo') }.to raise_error(BulkImports::Error, 'Unsupported export relation') + end + end + end end diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb index 21fe6cfb3fa..014f624165c 100644 --- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::FileTransfer::ProjectConfig do +RSpec.describe BulkImports::FileTransfer::ProjectConfig, feature_category: :importers do let_it_be(:exportable) { create(:project) } let_it_be(:hex) { '123' } @@ -109,4 +109,31 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects', 'repository', 'design') end end + + describe '#batchable_relation?' do + context 'when relation is batchable' do + it 'returns true' do + expect(subject.batchable_relation?('issues')).to eq(true) + end + end + + context 'when relation is not batchable' do + it 'returns false' do + expect(subject.batchable_relation?('project_feature')).to eq(false) + end + end + + context 'when relation is not listed as portable' do + it 'returns false' do + expect(subject.batchable_relation?('foo')).to eq(false) + end + end + end + + describe '#batchable_relations' do + it 'returns a list of collection associations for a project' do + expect(subject.batchable_relations).to include('issues', 'merge_requests', 'milestones') + expect(subject.batchable_relations).not_to include('project_feature', 'ci_cd_settings') + end + end end diff --git a/spec/services/bulk_imports/batched_relation_export_service_spec.rb b/spec/services/bulk_imports/batched_relation_export_service_spec.rb new file mode 100644 index 00000000000..c361dfe5052 --- /dev/null +++ b/spec/services/bulk_imports/batched_relation_export_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::BatchedRelationExportService, feature_category: :importers do + let_it_be(:user) { create(:user) } + let_it_be(:portable) { create(:group) } + + let(:relation) { 'labels' } + let(:jid) { '123' } + + subject(:service) { described_class.new(user, portable, relation, jid) } + + describe '#execute' do + context 'when there are batches to export' do + let_it_be(:label) { create(:group_label, group: portable) } + + it 'marks export as started' do + service.execute + + export = portable.bulk_import_exports.first + + expect(export.reload.started?).to eq(true) + end + + it 'removes existing batches' do + expect_next_instance_of(BulkImports::Export) do |export| + expect(export.batches).to receive(:destroy_all) + end + + service.execute + end + + it 'enqueues export jobs for each batch & caches batch record ids' do + expect(BulkImports::RelationBatchExportWorker).to receive(:perform_async) + expect(Gitlab::Cache::Import::Caching).to receive(:set_add) + + service.execute + end + + it 'enqueues FinishBatchedRelationExportWorker' do + expect(BulkImports::FinishBatchedRelationExportWorker).to receive(:perform_async) + + service.execute + end + + context 'when there are multiple batches' do + it 'creates a batch record for each batch of records' do + stub_const("#{described_class.name}::BATCH_SIZE", 1) + + create_list(:group_label, 10, group: portable) + + service.execute + + export = portable.bulk_import_exports.first + + expect(export.batches.count).to eq(11) + end + end + end + + context 'when there are no batches to export' do + let(:relation) { 'milestones' } + + it 'marks export as finished' do + service.execute + + export = portable.bulk_import_exports.first + + expect(export.finished?).to eq(true) + expect(export.batches.count).to eq(0) + end + end + + context 'when exception occurs' do + it 'tracks exception and marks export as failed' do + allow_next_instance_of(BulkImports::Export) do |export| + allow(export).to receive(:update!).and_call_original + + allow(export) + .to receive(:update!) + .with(status_event: 'finish', total_objects_count: 0, batched: true, batches_count: 0, jid: jid, error: nil) + .and_raise(StandardError, 'Error!') + end + + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(StandardError, portable_id: portable.id, portable_type: portable.class.name) + + service.execute + + export = portable.bulk_import_exports.first + + expect(export.reload.failed?).to eq(true) + end + end + end + + describe '.cache_key' do + it 'returns cache key given export and batch ids' do + expect(described_class.cache_key(1, 1)).to eq('bulk_imports/batched_relation_export/1/1') + end + end +end diff --git a/spec/services/bulk_imports/export_service_spec.rb b/spec/services/bulk_imports/export_service_spec.rb index ac7514fde5b..25a4547477c 100644 --- a/spec/services/bulk_imports/export_service_spec.rb +++ b/spec/services/bulk_imports/export_service_spec.rb @@ -13,17 +13,36 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do subject { described_class.new(portable: group, user: user) } describe '#execute' do - it 'schedules RelationExportWorker for each top level relation' do - expect(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original - top_level_relations = BulkImports::FileTransfer.config_for(group).portable_relations - - top_level_relations.each do |relation| - expect(BulkImports::RelationExportWorker) - .to receive(:perform_async) - .with(user.id, group.id, group.class.name, relation) + let_it_be(:top_level_relations) { BulkImports::FileTransfer.config_for(group).portable_relations } + + before do + allow(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original + end + + context 'when export is not batched' do + it 'schedules RelationExportWorker for each top level relation' do + top_level_relations.each do |relation| + expect(BulkImports::RelationExportWorker) + .to receive(:perform_async) + .with(user.id, group.id, group.class.name, relation, false) + end + + subject.execute end + end + + context 'when export is batched' do + subject { described_class.new(portable: group, user: user, batched: true) } - subject.execute + it 'schedules RelationExportWorker with a `batched: true` flag' do + top_level_relations.each do |relation| + expect(BulkImports::RelationExportWorker) + .to receive(:perform_async) + .with(user.id, group.id, group.class.name, relation, true) + end + + subject.execute + end end context 'when exception occurs' do @@ -38,6 +57,20 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do service.execute end + + context 'when user is not allowed to perform export' do + let(:another_user) { create(:user) } + + it 'does not schedule RelationExportWorker' do + another_user = create(:user) + service = described_class.new(portable: group, user: another_user) + response = service.execute + + expect(response.status).to eq(:error) + expect(response.message).to eq(Gitlab::ImportExport::Error) + expect(response.http_status).to eq(:unprocessable_entity) + end + end end end end diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb index 3c23b86ad5c..001fccb2054 100644 --- a/spec/services/bulk_imports/file_export_service_spec.rb +++ b/spec/services/bulk_imports/file_export_service_spec.rb @@ -5,18 +5,20 @@ require 'spec_helper' RSpec.describe BulkImports::FileExportService, feature_category: :importers do let_it_be(:project) { create(:project) } + let(:relations) do + { + 'uploads' => BulkImports::UploadsExportService, + 'lfs_objects' => BulkImports::LfsObjectsExportService, + 'repository' => BulkImports::RepositoryBundleExportService, + 'design' => BulkImports::RepositoryBundleExportService + } + end + describe '#execute' do it 'executes export service and archives exported data for each file relation' do - relations = { - 'uploads' => BulkImports::UploadsExportService, - 'lfs_objects' => BulkImports::LfsObjectsExportService, - 'repository' => BulkImports::RepositoryBundleExportService, - 'design' => BulkImports::RepositoryBundleExportService - } - relations.each do |relation, klass| Dir.mktmpdir do |export_path| - service = described_class.new(project, export_path, relation) + service = described_class.new(project, export_path, relation, nil) expect_next_instance_of(klass) do |service| expect(service).to receive(:execute) @@ -31,18 +33,58 @@ RSpec.describe BulkImports::FileExportService, feature_category: :importers do context 'when unsupported relation is passed' do it 'raises an error' do - service = described_class.new(project, nil, 'unsupported') + service = described_class.new(project, nil, 'unsupported', nil) expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type') end end end + describe '#execute_batch' do + it 'calls execute with provided array of record ids' do + relations.each do |relation, klass| + Dir.mktmpdir do |export_path| + service = described_class.new(project, export_path, relation, nil) + + expect_next_instance_of(klass) do |service| + expect(service).to receive(:execute).with({ batch_ids: [1, 2, 3] }) + end + + service.export_batch([1, 2, 3]) + end + end + end + end + describe '#exported_filename' do it 'returns filename of the exported file' do - service = described_class.new(project, nil, 'uploads') + service = described_class.new(project, nil, 'uploads', nil) expect(service.exported_filename).to eq('uploads.tar') end end + + describe '#exported_objects_count' do + context 'when relation is a collection' do + it 'returns a number of exported relations' do + %w[uploads lfs_objects].each do |relation| + service = described_class.new(project, nil, relation, nil) + + allow(service).to receive_message_chain(:export_service, :exported_objects_count).and_return(10) + + expect(service.exported_objects_count).to eq(10) + end + end + end + + context 'when relation is a repository' do + it 'returns 1' do + %w[repository design].each do |relation| + service = described_class.new(project, nil, relation, nil) + + expect(service.exported_objects_count).to eq(1) + end + end + end + end end diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb index 4f721a3a259..587c99d9897 100644 --- a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb +++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb @@ -53,6 +53,19 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer ) end + context 'when export is batched' do + it 'exports only specified lfs objects' do + new_lfs_object = create(:lfs_object, :with_file) + + project.lfs_objects << new_lfs_object + + service.execute(batch_ids: [new_lfs_object.id]) + + expect(File).to exist(File.join(export_path, new_lfs_object.oid)) + expect(File).not_to exist(File.join(export_path, lfs_object.oid)) + end + end + context 'when lfs object has file on disk missing' do it 'does not attempt to copy non-existent file' do FileUtils.rm(lfs_object.file.path) @@ -79,4 +92,14 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer end end end + + describe '#exported_objects_count' do + it 'return the number of exported lfs objects' do + project.lfs_objects << create(:lfs_object, :with_file) + + service.execute + + expect(service.exported_objects_count).to eq(2) + end + end end diff --git a/spec/services/bulk_imports/relation_batch_export_service_spec.rb b/spec/services/bulk_imports/relation_batch_export_service_spec.rb new file mode 100644 index 00000000000..c3abd02aff8 --- /dev/null +++ b/spec/services/bulk_imports/relation_batch_export_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::RelationBatchExportService, feature_category: :importers do + let_it_be(:project) { create(:project) } + let_it_be(:label) { create(:label, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:export) { create(:bulk_import_export, :batched, project: project) } + let_it_be(:batch) { create(:bulk_import_export_batch, export: export) } + let_it_be(:cache_key) { BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) } + + subject(:service) { described_class.new(user.id, batch.id) } + + before(:all) do + Gitlab::Cache::Import::Caching.set_add(cache_key, label.id) + end + + after(:all) do + Gitlab::Cache::Import::Caching.expire(cache_key, 0) + end + + describe '#execute' do + it 'exports relation batch' do + expect(Gitlab::Cache::Import::Caching).to receive(:values_from_set).with(cache_key).and_call_original + + service.execute + batch.reload + + expect(batch.finished?).to eq(true) + expect(batch.objects_count).to eq(1) + expect(batch.error).to be_nil + expect(export.upload.export_file).to be_present + end + + it 'removes exported contents after export' do + double = instance_double(BulkImports::FileTransfer::ProjectConfig, export_path: 'foo') + + allow(BulkImports::FileTransfer).to receive(:config_for).and_return(double) + allow(double).to receive(:export_service_for).and_raise(StandardError, 'Error!') + allow(FileUtils).to receive(:remove_entry) + + expect(FileUtils).to receive(:remove_entry).with('foo') + + service.execute + end + + context 'when exception occurs' do + before do + allow(service).to receive(:gzip).and_raise(StandardError, 'Error!') + end + + it 'marks batch as failed' do + expect(Gitlab::ErrorTracking) + .to receive(:track_exception) + .with(StandardError, portable_id: project.id, portable_type: 'Project') + + service.execute + batch.reload + + expect(batch.failed?).to eq(true) + expect(batch.objects_count).to eq(0) + expect(batch.error).to eq('Error!') + end + end + end +end diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb index bc999b0b9b3..1c050fe4143 100644 --- a/spec/services/bulk_imports/relation_export_service_spec.rb +++ b/spec/services/bulk_imports/relation_export_service_spec.rb @@ -35,6 +35,10 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers expect(export.reload.upload.export_file).to be_present expect(export.finished?).to eq(true) + expect(export.batched?).to eq(false) + expect(export.batches_count).to eq(0) + expect(export.batches.count).to eq(0) + expect(export.total_objects_count).to eq(0) end it 'removes temp export files' do @@ -133,13 +137,23 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers include_examples 'tracks exception', ActiveRecord::RecordInvalid end + end + + context 'when export was batched' do + let(:relation) { 'milestones' } + let(:export) { create(:bulk_import_export, group: group, relation: relation, batched: true, batches_count: 2) } - context 'when user is not allowed to perform export' do - let(:another_user) { create(:user) } + it 'removes existing batches and marks export as not batched' do + create(:bulk_import_export_batch, batch_number: 1, export: export) + create(:bulk_import_export_batch, batch_number: 2, export: export) - subject { described_class.new(another_user, group, relation, jid) } + expect { described_class.new(user, group, relation, jid).execute } + .to change { export.reload.batches.count } + .from(2) + .to(0) - include_examples 'tracks exception', Gitlab::ImportExport::Error + expect(export.batched?).to eq(false) + expect(export.batches_count).to eq(0) end end end diff --git a/spec/services/bulk_imports/tree_export_service_spec.rb b/spec/services/bulk_imports/tree_export_service_spec.rb index fa96641f1c1..ae78858976f 100644 --- a/spec/services/bulk_imports/tree_export_service_spec.rb +++ b/spec/services/bulk_imports/tree_export_service_spec.rb @@ -53,4 +53,14 @@ RSpec.describe BulkImports::TreeExportService, feature_category: :importers do end end end + + describe '#export_batch' do + it 'serializes relation with specified ids' do + expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer| + expect(serializer).to receive(:serialize_relation).with(anything, batch_ids: [1, 2, 3]) + end + + subject.export_batch([1, 2, 3]) + end + end end diff --git a/spec/services/bulk_imports/uploads_export_service_spec.rb b/spec/services/bulk_imports/uploads_export_service_spec.rb index 8dc67b28d12..709ade4a504 100644 --- a/spec/services/bulk_imports/uploads_export_service_spec.rb +++ b/spec/services/bulk_imports/uploads_export_service_spec.rb @@ -3,9 +3,8 @@ require 'spec_helper' RSpec.describe BulkImports::UploadsExportService, feature_category: :importers do - let_it_be(:export_path) { Dir.mktmpdir } - let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) } - + let(:export_path) { Dir.mktmpdir } + let(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) } let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) } let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) } @@ -23,6 +22,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d expect(File).to exist(exported_filepath) end + context 'when export is batched' do + it 'exports only specified uploads' do + service.execute(batch_ids: [upload.id]) + + expect(service.exported_objects_count).to eq(1) + expect(File).not_to exist(File.join(export_path, 'avatar', 'rails_sample.png')) + expect(File).to exist(exported_filepath) + end + end + context 'when upload has underlying file missing' do context 'with an upload missing its file' do it 'does not cause errors' do @@ -53,6 +62,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d } ) + expect(Gitlab::ErrorTracking) + .to receive(:log_exception) + .with( + instance_of(exception), { + portable_id: project.id, + portable_class: 'Project', + upload_id: project.avatar.upload.id + } + ) + service.execute expect(File).not_to exist(exported_filepath) @@ -73,4 +92,12 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d end end end + + describe '#exported_objects_count' do + it 'return the number of exported uploads' do + service.execute + + expect(service.exported_objects_count).to eq(2) + end + end end diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb index 5cc17f55012..71228050085 100644 --- a/spec/services/system_notes/time_tracking_service_spec.rb +++ b/spec/services/system_notes/time_tracking_service_spec.rb @@ -37,7 +37,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann end it 'sets the correct note message' do - expect(note.note).to eq('removed start date and removed due date') + expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}") end end @@ -52,7 +52,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann let(:changed_dates) { { 'due_date' => [nil, due_date], 'start_date' => [start_date, nil] } } it 'sets the correct note message' do - expect(note.note).to eq("removed start date and changed due date to #{due_date.to_s(:long)}") + expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and changed due date to #{due_date.to_s(:long)}") end end end @@ -80,7 +80,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann let(:changed_dates) { { 'due_date' => [due_date, nil], 'start_date' => [nil, start_date] } } it 'sets the correct note message' do - expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date") + expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}") end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c3bddf1a6ae..334c709dcf8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -311,10 +311,6 @@ RSpec.configure do |config| # See https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor stub_feature_flags(legacy_merge_request_state_check_for_merged_result_pipelines: false) - # Disable the `vue_issues_dashboard` feature flag in specs as we migrate the issues - # dashboard page to Vue. https://gitlab.com/gitlab-org/gitlab/-/issues/379025 - stub_feature_flags(vue_issues_dashboard: false) - allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) else unstub_all_feature_flags diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index b07f5dcf2e1..ecc749b1e45 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -69,12 +69,6 @@ module FilteredSearchHelpers filtered_search.send_keys(:enter) end - def init_label_search - filtered_search.set('label:=') - # This ensures the dropdown is shown - expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading') - end - def expect_filtered_search_input_empty expect(find('.filtered-search').value).to eq('') end diff --git a/spec/views/projects/issues/_related_issues.html.haml_spec.rb b/spec/views/projects/issues/_related_issues.html.haml_spec.rb new file mode 100644 index 00000000000..0dbca032c4b --- /dev/null +++ b/spec/views/projects/issues/_related_issues.html.haml_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'projects/issues/_related_issues.html.haml', feature_category: :team_planning do + let_it_be(:project) { build_stubbed(:project) } + let_it_be(:issue) { build_stubbed(:issue, project: project) } + + context 'when current user cannot read issue link for the project' do + before do + allow(view).to receive(:can?).and_return(false) + end + + it 'does not render the related issues root node' do + render + + expect(rendered).not_to have_selector(".js-related-issues-root") + end + end + + context 'when current user can read issue link for the project' do + before do + allow(view).to receive(:can?).and_return(true) + + assign(:project, project) + assign(:issue, issue) + end + + it 'adds the report abuse path as a data attribute' do + render + + expect(rendered).to have_selector( + ".js-related-issues-root[data-report-abuse-path=\"#{add_category_abuse_reports_path}\"]" + ) + end + end +end diff --git a/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb new file mode 100644 index 00000000000..6fbcb267c0a --- /dev/null +++ b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::FinishBatchedRelationExportWorker, feature_category: :importers do + let(:export) { create(:bulk_import_export, :started) } + let(:batch) { create(:bulk_import_export_batch, :finished, export: export) } + let(:export_id) { export.id } + let(:job_args) { [export_id] } + + describe '#perform' do + it_behaves_like 'an idempotent worker' do + it 'marks export as finished and expires batches cache' do + cache_key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) + + expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(cache_key, 0) + + perform_multiple(job_args) + + expect(export.reload.finished?).to eq(true) + end + + context 'when export is finished' do + let(:export) { create(:bulk_import_export, :finished) } + + it 'returns without updating export' do + perform_multiple(job_args) + + expect(export.reload.finished?).to eq(true) + end + end + + context 'when export is failed' do + let(:export) { create(:bulk_import_export, :failed) } + + it 'returns without updating export' do + perform_multiple(job_args) + + expect(export.reload.failed?).to eq(true) + end + end + + context 'when export is in progress' do + it 'reenqueues itself' do + create(:bulk_import_export_batch, :started, export: export) + + expect(described_class).to receive(:perform_in).twice + + perform_multiple(job_args) + + expect(export.reload.started?).to eq(true) + end + end + + context 'when export timed out' do + it 'marks export as failed' do + expect(export.reload.failed?).to eq(false) + expect(batch.reload.failed?).to eq(false) + + export.update!(updated_at: 1.day.ago) + + perform_multiple(job_args) + + expect(export.reload.failed?).to eq(true) + expect(batch.reload.failed?).to eq(true) + end + end + + context 'when export is missing' do + let(:export_id) { nil } + + it 'returns' do + expect(described_class).not_to receive(:perform_in) + + perform_multiple(job_args) + end + end + end + end +end diff --git a/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb new file mode 100644 index 00000000000..4a2c8d48742 --- /dev/null +++ b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::RelationBatchExportWorker, feature_category: :importers do + let_it_be(:user) { create(:user) } + let_it_be(:batch) { create(:bulk_import_export_batch) } + + let(:job_args) { [user.id, batch.id] } + + describe '#perform' do + include_examples 'an idempotent worker' do + it 'executes RelationBatchExportService' do + service = instance_double(BulkImports::RelationBatchExportService) + + expect(BulkImports::RelationBatchExportService) + .to receive(:new) + .with(user.id, batch.id) + .twice.and_return(service) + expect(service).to receive(:execute).twice + + perform_multiple(job_args) + end + end + end +end diff --git a/spec/workers/bulk_imports/relation_export_worker_spec.rb b/spec/workers/bulk_imports/relation_export_worker_spec.rb index c2f7831896b..38ef4df263e 100644 --- a/spec/workers/bulk_imports/relation_export_worker_spec.rb +++ b/spec/workers/bulk_imports/relation_export_worker_spec.rb @@ -4,17 +4,18 @@ require 'spec_helper' RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers do let_it_be(:jid) { 'jid' } - let_it_be(:relation) { 'labels' } let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } - let(:job_args) { [user.id, group.id, group.class.name, relation] } + let(:batched) { false } + let(:relation) { 'labels' } + let(:job_args) { [user.id, group.id, group.class.name, relation, batched] } describe '#perform' do include_examples 'an idempotent worker' do context 'when export record does not exist' do let(:another_group) { create(:group) } - let(:job_args) { [user.id, another_group.id, another_group.class.name, relation] } + let(:job_args) { [user.id, another_group.id, another_group.class.name, relation, batched] } it 'creates export record' do another_group.add_owner(user) @@ -26,21 +27,37 @@ RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers d end end - it 'executes RelationExportService' do - group.add_owner(user) + shared_examples 'export service' do |export_service| + it 'executes export service' do + group.add_owner(user) - service = instance_double(BulkImports::RelationExportService) + service = instance_double(export_service) - expect(BulkImports::RelationExportService) - .to receive(:new) - .with(user, group, relation, anything) - .twice - .and_return(service) - expect(service) - .to receive(:execute) - .twice + expect(export_service) + .to receive(:new) + .with(user, group, relation, anything) + .twice + .and_return(service) + expect(service).to receive(:execute).twice - perform_multiple(job_args) + perform_multiple(job_args) + end + end + + context 'when export is batched' do + let(:batched) { true } + + include_examples 'export service', BulkImports::BatchedRelationExportService + + context 'when relation is not batchable' do + let(:relation) { 'namespace_settings' } + + include_examples 'export service', BulkImports::RelationExportService + end + end + + context 'when export is not batched' do + include_examples 'export service', BulkImports::RelationExportService end end end diff --git a/spec/workers/pipeline_process_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb index 6c6851c51ce..1c76cdca347 100644 --- a/spec/workers/pipeline_process_worker_spec.rb +++ b/spec/workers/pipeline_process_worker_spec.rb @@ -5,6 +5,48 @@ require 'spec_helper' RSpec.describe PipelineProcessWorker, feature_category: :continuous_integration do let_it_be(:pipeline) { create(:ci_pipeline) } + # The two examples below are to be added when FF `ci_pipeline_process_worker_dedup_until_executed` is removed + # it 'has the `until_executed` deduplicate strategy' do + # expect(described_class.get_deduplicate_strategy).to eq(:until_executed) + # end + + # it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do + # expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute }) + # end + + # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed + describe '#perform_async', :sidekiq_inline do + around do |example| + Sidekiq::Testing.fake! { example.run } + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project) } + + subject { described_class.perform_async(pipeline.id) } + + it 'sets the deduplication settings in the job options' do + subject + + job = described_class.jobs.last + expect(job['deduplicate']).to eq({ 'strategy' => 'until_executed', + 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } }) + end + + context 'when FF `ci_pipeline_process_worker_dedup_until_executed` is disabled' do + before do + stub_feature_flags(ci_pipeline_process_worker_dedup_until_executed: false) + end + + it 'does not set the deduplication settings in the job options' do + subject + + job = described_class.jobs.last + expect(job['deduplicate']).to be_nil + end + end + end + include_examples 'an idempotent worker' do let(:pipeline) { create(:ci_pipeline, :created) } let(:job_args) { [pipeline.id] } |