diff options
Diffstat (limited to 'spec/support/shared_examples')
42 files changed, 2360 insertions, 395 deletions
diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb index 15590fd10dc..0e6f6f12c3f 100644 --- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'switches current board' do in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end wait_for_requests @@ -66,7 +66,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'adds a list to the none default board' do in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end wait_for_requests @@ -88,7 +88,7 @@ RSpec.shared_examples 'multiple issue boards' do expect(page).to have_selector('.board', count: 3) in_boards_switcher_dropdown do - click_link board.name + click_button board.name end wait_for_requests @@ -100,7 +100,7 @@ RSpec.shared_examples 'multiple issue boards' do assert_boards_nav_active in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end assert_boards_nav_active @@ -108,7 +108,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'switches current board back' do in_boards_switcher_dropdown do - click_link board.name + click_button board.name end wait_for_requests diff --git a/spec/support/shared_examples/components/pajamas_shared_examples.rb b/spec/support/shared_examples/components/pajamas_shared_examples.rb new file mode 100644 index 00000000000..5c0ad1a1bc9 --- /dev/null +++ b/spec/support/shared_examples/components/pajamas_shared_examples.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'it renders help text' do + it 'renders help text' do + expect(rendered_component).to have_selector('[data-testid="pajamas-component-help-text"]', text: help_text) + end +end + +RSpec.shared_examples 'it does not render help text' do + it 'does not render help text' do + expect(rendered_component).not_to have_selector('[data-testid="pajamas-component-help-text"]') + end +end diff --git a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb index a79b94209f3..c6e880635aa 100644 --- a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb @@ -65,20 +65,3 @@ RSpec.shared_examples 'failed response for #cancel_auto_stop' do end end end - -RSpec.shared_examples 'avoids N+1 queries on environment detail page' do - render_views - - before do - create_deployment_with_associations(sequence: 0) - end - - it 'avoids N+1 queries' do - control = ActiveRecord::QueryRecorder.new { get :show, params: environment_params } - - create_deployment_with_associations(sequence: 1) - create_deployment_with_associations(sequence: 2) - - expect { get :show, params: environment_params }.not_to exceed_query_limit(control.count).with_threshold(34) - end -end diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb index 2ea98002de1..5faf462c23c 100644 --- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb @@ -36,6 +36,19 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST personal_access_toke expect(session[:"#{provider}_access_token"]).to eq(token) expect(controller).to redirect_to(status_import_url) end + + it 'passes namespace_id param as query param if it was present' do + namespace_id = 5 + status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) + + allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| + allow(client).to receive(:user).and_return(true) + end + + post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } + + expect(controller).to redirect_to(status_import_url) + end end RSpec.shared_examples 'a GitHub-ish import controller: GET new' do diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb index 94c91556ea7..44f30c32472 100644 --- a/spec/support/shared_examples/features/2fa_shared_examples.rb +++ b/spec/support/shared_examples/features/2fa_shared_examples.rb @@ -2,6 +2,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| include Spec::Support::Helpers::Features::TwoFactorHelpers + include Spec::Support::Helpers::ModalHelpers def register_device(device_type, **kwargs) case device_type.downcase @@ -18,7 +19,6 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| let(:user) { create(:user) } before do - stub_feature_flags(bootstrap_confirmation_modals: false) gitlab_sign_in(user) user.update_attribute(:otp_required_for_login, true) end @@ -59,7 +59,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| expect(page).to have_content(first_device.name) expect(page).to have_content(second_device.name) - accept_confirm { click_on 'Delete', match: :first } + accept_gl_confirm(button_text: 'Delete') { click_on 'Delete', match: :first } expect(page).to have_content('Successfully deleted') expect(page.body).not_to have_content(first_device.name) diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb index 215d9d3e5a8..c162ed36881 100644 --- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb +++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb @@ -51,7 +51,7 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes it 'does not show access token creation form' do visit resource_settings_access_tokens_path - expect(page).not_to have_selector('#new_resource_access_token') + expect(page).not_to have_selector('#js-new-access-token-form') end it 'shows access token creation disabled text' do @@ -135,7 +135,7 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex it 'allows revocation of an active token' do visit resource_settings_access_tokens_path - accept_confirm { click_on 'Revoke' } + accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' } expect(page).to have_selector('.settings-message') expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) @@ -156,7 +156,7 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex it 'allows revocation of an active token' do visit resource_settings_access_tokens_path - accept_confirm { click_on 'Revoke' } + accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' } expect(page).to have_selector('.settings-message') expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) diff --git a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb index 395f4fc54e0..cb80751ff49 100644 --- a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb +++ b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb @@ -6,7 +6,8 @@ RSpec.shared_examples 'a cascading setting' do visit group_path page.within form_group_selector do - find(setting_field_selector).check + enable_setting.call + find('[data-testid="enforce-for-all-subgroups-checkbox"]').check end diff --git a/spec/support/shared_examples/features/container_registry_shared_examples.rb b/spec/support/shared_examples/features/container_registry_shared_examples.rb index 6aa7e6e6270..784f82fdda1 100644 --- a/spec/support/shared_examples/features/container_registry_shared_examples.rb +++ b/spec/support/shared_examples/features/container_registry_shared_examples.rb @@ -19,8 +19,7 @@ RSpec.shared_examples 'rejecting tags destruction for an importing repository on expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click - alert_body = find('.gl-alert-body') - expect(alert_body).to have_content('Tags temporarily cannot be marked for deletion. Please try again in a few minutes.') - expect(alert_body).to have_link('More details', href: help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion')) + expect(page).to have_content('Tags temporarily cannot be marked for deletion. Please try again in a few minutes.') + expect(page).to have_link('More details', href: help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion')) end end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index c93d8e3d511..591f7973454 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -21,6 +21,31 @@ RSpec.shared_examples 'edits content using the content editor' do end end + describe 'code block' do + before do + visit(profile_preferences_path) + + find('.syntax-theme').choose('Dark') + + wait_for_requests + + page.go_back + refresh + + click_button 'Edit rich text' + end + + it 'applies theme classes to code blocks' do + expect(page).not_to have_css('.content-editor-code-block.code.highlight.dark') + + find(content_editor_testid).send_keys [:enter, :enter] + find(content_editor_testid).send_keys '```js ' # trigger input rule + find(content_editor_testid).send_keys 'var a = 0' + + expect(page).to have_css('.content-editor-code-block.code.highlight.dark') + end + end + describe 'code block bubble menu' do it 'shows a code block bubble menu for a code block' do find(content_editor_testid).send_keys [:enter, :enter] @@ -51,4 +76,49 @@ RSpec.shared_examples 'edits content using the content editor' do expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)') end end + + describe 'mermaid diagram' do + before do + find(content_editor_testid).send_keys [:enter, :enter] + + find(content_editor_testid).send_keys '```mermaid ' + find(content_editor_testid).send_keys ['graph TD;', :enter, ' JohnDoe12 --> HelloWorld34'] + end + + it 'renders and updates the diagram correctly in a sandboxed iframe' do + iframe = find(content_editor_testid).find('iframe') + expect(iframe['src']).to include('/-/sandbox/mermaid') + + within_frame(iframe) do + expect(find('svg').text).to include('JohnDoe12') + expect(find('svg').text).to include('HelloWorld34') + end + + expect(iframe['height'].to_i).to be > 100 + + find(content_editor_testid).send_keys [:enter, ' JaneDoe34 --> HelloWorld56'] + + within_frame(iframe) do + page.has_content?('JaneDoe34') + + expect(find('svg').text).to include('JaneDoe34') + expect(find('svg').text).to include('HelloWorld56') + end + end + + it 'toggles the diagram when preview button is clicked' do + find('[data-testid="preview-diagram"]').click + + expect(find(content_editor_testid)).not_to have_selector('iframe') + + find('[data-testid="preview-diagram"]').click + + iframe = find(content_editor_testid).find('iframe') + + within_frame(iframe) do + expect(find('svg').text).to include('JohnDoe12') + expect(find('svg').text).to include('HelloWorld34') + end + end + end end diff --git a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb deleted file mode 100644 index 1848b4fffd9..00000000000 --- a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'issuable user dropdown behaviors' do - include FilteredSearchHelpers - - before do - issuable # ensure we have at least one issuable - sign_in(user_in_dropdown) - end - - %w[author assignee].each do |dropdown| - describe "#{dropdown} dropdown", :js do - it 'only includes members of the project/group' do - visit issuables_path - - filtered_search.set("#{dropdown}:=") - - expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name) - expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name) - end - end - end -end diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb index d9460c7b8f1..52f3fd60c07 100644 --- a/spec/support/shared_examples/features/runners_shared_examples.rb +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -35,11 +35,11 @@ RSpec.shared_examples 'shows and resets runner registration token' do it 'has a registration token' do click_on 'Click to reveal' - expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token) + expect(page.find_field('token-value').value).to have_content(registration_token) end describe 'reset registration token' do - let!(:old_registration_token) { find('[data-testid="token-value"] input').value } + let!(:old_registration_token) { find_field('token-value').value } before do click_on 'Reset registration token' @@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do end end -RSpec.shared_examples 'shows no runners' do +RSpec.shared_examples 'shows no runners registered' do it 'shows counts with 0' do expect(page).to have_text "Online runners 0" expect(page).to have_text "Offline runners 0" @@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do end it 'shows "no runners" message' do - expect(page).to have_text 'No runners found' + expect(page).to have_text s_('Runners|Get started with runners') + end +end + +RSpec.shared_examples 'shows no runners found' do + it 'shows "no runners" message' do + expect(page).to have_text s_('Runners|No results found') end end RSpec.shared_examples 'shows runner in list' do it 'does not show empty state' do - expect(page).not_to have_content 'No runners found' + expect(page).not_to have_content s_('Runners|Get started with runners') end it 'shows runner row' do diff --git a/spec/support/shared_examples/features/sidebar_shared_examples.rb b/spec/support/shared_examples/features/sidebar_shared_examples.rb index af3ea0600a2..77334db6a36 100644 --- a/spec/support/shared_examples/features/sidebar_shared_examples.rb +++ b/spec/support/shared_examples/features/sidebar_shared_examples.rb @@ -109,9 +109,8 @@ RSpec.shared_examples 'issue boards sidebar' do wait_for_requests expect(page).to have_content( - _('Only project members with at least' \ - ' Reporter role can view or be' \ - ' notified about this issue.') + _('Only project members with at least the Reporter role, the author, and assignees' \ + ' can view or be notified about this issue.') ) end end diff --git a/spec/support/shared_examples/finders/issues_finder_shared_examples.rb b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb new file mode 100644 index 00000000000..622a88e8323 --- /dev/null +++ b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb @@ -0,0 +1,1471 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'issues or work items finder' do |factory, execute_context| + describe '#execute' do + include_context execute_context + + context 'scope: all' do + let(:scope) { 'all' } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + + context 'user does not have read permissions' do + let(:search_user) { user2 } + + context 'when filtering by project id' do + let(:params) { { project_id: project1.id } } + + it 'returns no items' do + expect(items).to be_empty + end + end + + context 'when filtering by group id' do + let(:params) { { group_id: group.id } } + + it 'returns no items' do + expect(items).to be_empty + end + end + end + + context 'assignee filtering' do + let(:issuables) { items } + + it_behaves_like 'assignee ID filter' do + let(:params) { { assignee_id: user.id } } + let(:expected_issuables) { [item1, item2, item5] } + end + + it_behaves_like 'assignee NOT ID filter' do + let(:params) { { not: { assignee_id: user.id } } } + let(:expected_issuables) { [item3, item4] } + end + + it_behaves_like 'assignee OR filter' do + let(:params) { { or: { assignee_id: [user.id, user2.id] } } } + let(:expected_issuables) { [item1, item2, item3, item5] } + end + + context 'when assignee_id does not exist' do + it_behaves_like 'assignee NOT ID filter' do + let(:params) { { not: { assignee_id: -100 } } } + let(:expected_issuables) { [item1, item2, item3, item4, item5] } + end + end + + context 'filter by username' do + let_it_be(:user3) { create(:user) } + + before do + project2.add_developer(user3) + item2.assignees = [user2] + item3.assignees = [user3] + end + + it_behaves_like 'assignee username filter' do + let(:params) { { assignee_username: [user2.username] } } + let(:expected_issuables) { [item2] } + end + + it_behaves_like 'assignee NOT username filter' do + before do + item2.assignees = [user2] + end + + let(:params) { { not: { assignee_username: [user.username, user2.username] } } } + let(:expected_issuables) { [item3, item4] } + end + + it_behaves_like 'assignee OR filter' do + let(:params) { { or: { assignee_username: [user2.username, user3.username] } } } + let(:expected_issuables) { [item2, item3] } + end + + context 'when assignee_username does not exist' do + it_behaves_like 'assignee NOT username filter' do + before do + item2.assignees = [user2] + end + + let(:params) { { not: { assignee_username: 'non_existent_username' } } } + let(:expected_issuables) { [item1, item2, item3, item4, item5] } + end + end + end + + it_behaves_like 'no assignee filter' do + let_it_be(:user3) { create(:user) } + let(:expected_issuables) { [item4] } + end + + it_behaves_like 'any assignee filter' do + let(:expected_issuables) { [item1, item2, item3, item5] } + end + end + + context 'filtering by release' do + context 'when the release tag is none' do + let(:params) { { release_tag: 'none' } } + + it 'returns items without releases' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'when the release tag exists' do + let(:params) { { project_id: project1.id, release_tag: release.tag } } + + it 'returns the items associated with that release' do + expect(items).to contain_exactly(item1) + end + end + end + + context 'filtering by projects' do + context 'when projects are passed in a list of ids' do + let(:params) { { projects: [project1.id] } } + + it 'returns the item belonging to the projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when projects are passed in a subquery' do + let(:params) { { projects: Project.id_in(project1.id) } } + + it 'returns the item belonging to the projects' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'filtering by group_id' do + let(:params) { { group_id: group.id } } + + context 'when include_subgroup param not set' do + it 'returns all group items' do + expect(items).to contain_exactly(item1, item5) + end + + context 'when projects outside the group are passed' do + let(:params) { { group_id: group.id, projects: [project2.id] } } + + it 'returns no items' do + expect(items).to be_empty + end + end + + context 'when projects of the group are passed' do + let(:params) { { group_id: group.id, projects: [project1.id] } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when projects of the group are passed as a subquery' do + let(:params) { { group_id: group.id, projects: Project.id_in(project1.id) } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when release_tag is passed as a parameter' do + let(:params) { { group_id: group.id, release_tag: 'dne-release-tag' } } + + it 'ignores the release_tag parameter' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'when include_subgroup param is true' do + before do + params[:include_subgroups] = true + end + + it 'returns all group and subgroup items' do + expect(items).to contain_exactly(item1, item4, item5) + end + + context 'when mixed projects are passed' do + let(:params) { { group_id: group.id, projects: [project2.id, project3.id] } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item4) + end + end + end + end + + context 'filtering by author' do + context 'by author ID' do + let(:params) { { author_id: user2.id } } + + it 'returns items created by that user' do + expect(items).to contain_exactly(item3) + end + end + + context 'using OR' do + let(:item6) { create(factory, project: project2) } + let(:params) { { or: { author_username: [item3.author.username, item6.author.username] } } } + + it 'returns items created by any of the given users' do + expect(items).to contain_exactly(item3, item6) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(or_issuable_queries: false) + end + + it 'does not add any filter' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5, item6) + end + end + end + + context 'filtering by NOT author ID' do + let(:params) { { not: { author_id: user2.id } } } + + it 'returns items not created by that user' do + expect(items).to contain_exactly(item1, item2, item4, item5) + end + end + + context 'filtering by nonexistent author ID and issue term using CTE for search' do + let(:params) do + { + author_id: 'does-not-exist', + search: 'git', + attempt_group_search_optimizations: true + } + end + + it 'returns no results' do + expect(items).to be_empty + end + end + end + + context 'filtering by milestone' do + let(:params) { { milestone_title: milestone.title } } + + it 'returns items assigned to that milestone' do + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by not milestone' do + let(:params) { { not: { milestone_title: milestone.title } } } + + it 'returns items not assigned to that milestone' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'filtering by group milestone' do + let!(:group) { create(:group, :public) } + let(:group_milestone) { create(:milestone, group: group) } + let!(:group_member) { create(:group_member, group: group, user: user) } + let(:params) { { milestone_title: group_milestone.title } } + + before do + project2.update!(namespace: group) + item2.update!(milestone: group_milestone) + item3.update!(milestone: group_milestone) + end + + it 'returns items assigned to that group milestone' do + expect(items).to contain_exactly(item2, item3) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: group_milestone.title } } } + + it 'returns items not assigned to that group milestone' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by no milestone' do + let(:params) { { milestone_title: 'None' } } + + it 'returns items with no milestone' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + + it 'returns items with no milestone (deprecated)' do + params[:milestone_title] = Milestone::None.title + + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'filtering by any milestone' do + let(:params) { { milestone_title: 'Any' } } + + it 'returns items with any assigned milestone' do + expect(items).to contain_exactly(item1) + end + + it 'returns items with any assigned milestone (deprecated)' do + params[:milestone_title] = Milestone::Any.title + + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by upcoming milestone' do + let(:params) { { milestone_title: Milestone::Upcoming.name } } + + let!(:group) { create(:group, :public) } + let!(:group_member) { create(:group_member, group: group, user: user) } + + let(:project_no_upcoming_milestones) { create(:project, :public) } + let(:project_next_1_1) { create(:project, :public) } + let(:project_next_8_8) { create(:project, :public) } + let(:project_in_group) { create(:project, :public, namespace: group) } + + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_from_now) { Date.current + 2.days } + let(:ten_days_from_now) { Date.current + 10.days } + + let(:milestones) do + [ + create(:milestone, :closed, project: project_no_upcoming_milestones), + create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now), + create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now), + create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday), + create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow), + create(:milestone, group: group, title: '9.9', due_date: tomorrow) + ] + end + + let!(:created_items) do + milestones.map do |milestone| + create(factory, project: milestone.project || project_in_group, + milestone: milestone, author: user, assignees: [user]) + end + end + + it 'returns items in the upcoming milestone for each project or group' do + expect(items.map { |item| item.milestone.title }) + .to contain_exactly('1.1', '8.8', '9.9') + expect(items.map { |item| item.milestone.due_date }) + .to contain_exactly(tomorrow, two_days_from_now, tomorrow) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } } + + it 'returns items not in upcoming milestones for each project or group, but must have a due date' do + target_items = created_items.select do |item| + item.milestone&.due_date && item.milestone.due_date <= Date.current + end + + expect(items).to contain_exactly(*target_items) + end + end + end + + context 'filtering by started milestone' do + let(:params) { { milestone_title: Milestone::Started.name } } + + let(:project_no_started_milestones) { create(:project, :public) } + let(:project_started_1_and_2) { create(:project, :public) } + let(:project_started_8) { create(:project, :public) } + + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_ago) { Date.current - 2.days } + let(:three_days_ago) { Date.current - 3.days } + + let(:milestones) do + [ + create(:milestone, project: project_no_started_milestones, start_date: tomorrow), + create(:milestone, project: project_started_1_and_2, title: '1.0', start_date: two_days_ago), + create(:milestone, project: project_started_1_and_2, title: '2.0', start_date: yesterday), + create(:milestone, project: project_started_1_and_2, title: '3.0', start_date: tomorrow), + create(:milestone, :closed, project: project_started_1_and_2, title: '4.0', start_date: three_days_ago), + create(:milestone, :closed, project: project_started_8, title: '6.0', start_date: three_days_ago), + create(:milestone, project: project_started_8, title: '7.0'), + create(:milestone, project: project_started_8, title: '8.0', start_date: yesterday), + create(:milestone, project: project_started_8, title: '9.0', start_date: tomorrow) + ] + end + + before do + milestones.each do |milestone| + create(factory, project: milestone.project, milestone: milestone, author: user, assignees: [user]) + end + end + + it 'returns items in the started milestones for each project' do + expect(items.map { |item| item.milestone.title }) + .to contain_exactly('1.0', '2.0', '8.0') + expect(items.map { |item| item.milestone.start_date }) + .to contain_exactly(two_days_ago, yesterday, yesterday) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Started.name } } } + + it 'returns items not in the started milestones for each project' do + target_items = items_model.where(milestone: Milestone.not_started) + + expect(items).to contain_exactly(*target_items) + end + end + end + + context 'filtering by label' do + let(:params) { { label_name: label.title } } + + it 'returns items with that label' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: label.title } } } + + it 'returns items that do not have that label' do + expect(items).to contain_exactly(item1, item3, item4, item5) + end + + # IssuableFinder first filters using the outer params (the ones not inside the `not` key.) + # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param + # do not take precedence over the outer params with the same name. + context 'shadowing the same outside param' do + let(:params) { { label_name: label2.title, not: { label_name: label.title } } } + + it 'does not take precedence over labels outside NOT' do + expect(items).to contain_exactly(item3) + end + end + + context 'further filtering outside params' do + let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } } + + it 'further filters on the returned resultset' do + expect(items).to be_empty + end + end + end + end + + context 'filtering by multiple labels' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label2) { create(:label, project: project2) } + + before do + create(:label_link, label: label2, target: item2) + end + + it 'returns the unique items with all those labels' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns items that do not have any of the labels provided' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by a label that includes any or none in the title' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label) { create(:label, title: 'any foo', project: project2) } + let(:label2) { create(:label, title: 'bar none', project: project2) } + + before do + create(:label_link, label: label2, target: item2) + end + + it 'returns the unique items with all those labels' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns items that do not have ANY ONE of the labels provided' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by no label' do + let(:params) { { label_name: described_class::Params::FILTER_NONE } } + + it 'returns items with no labels' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + + context 'filtering by any label' do + let(:params) { { label_name: described_class::Params::FILTER_ANY } } + + it 'returns items that have one or more label' do + create_list(:label_link, 2, label: create(:label, project: project2), target: item3) + + expect(items).to contain_exactly(item2, item3) + end + end + + context 'when the same label exists on project and group levels' do + let(:item1) { create(factory, project: project1) } + let(:item2) { create(factory, project: project1) } + + # Skipping validation to reproduce a "real-word" scenario. + # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug` + let(:project_label) do + build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } + end + + let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) } + + let(:params) { { label_name: 'somelabel' } } + + before do + create(:label_link, label: group_label, target: item1) + create(:label_link, label: project_label, target: item2) + end + + it 'finds both item records' do + expect(items).to contain_exactly(item1, item2) + end + end + + context 'filtering by item term' do + let(:params) { { search: search_term } } + + let_it_be(:english) { create(factory, project: project1, title: 'title', description: 'something english') } + + let_it_be(:japanese) do + create(factory, project: project1, title: '日本語 title', description: 'another english description') + end + + context 'with latin search term' do + let(:search_term) { 'title english' } + + it 'returns matching items' do + expect(items).to contain_exactly(english, japanese) + end + end + + context 'with non-latin search term' do + let(:search_term) { '日本語' } + + it 'returns matching items' do + expect(items).to contain_exactly(japanese) + end + end + + context 'when full-text search is disabled' do + let(:search_term) { 'somet' } + + before do + stub_feature_flags(issues_full_text_search: false) + end + + it 'allows partial word matches' do + expect(items).to contain_exactly(english) + end + end + + context 'with anonymous user' do + let_it_be(:public_project) { create(:project, :public, group: subgroup) } + let_it_be(:item6) { create(factory, project: public_project, title: 'tanuki') } + let_it_be(:item7) { create(factory, project: public_project, title: 'ikunat') } + + let(:search_user) { nil } + let(:params) { { search: 'tanuki' } } + + context 'with disable_anonymous_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not perform search' do + expect(items).to contain_exactly(item6, item7) + end + end + + context 'with disable_anonymous_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'finds one public item' do + expect(items).to contain_exactly(item6) + end + end + end + end + + context 'filtering by item term in title' do + let(:params) { { search: 'git', in: 'title' } } + + it 'returns items with title match for search term' do + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by items iids' do + let(:params) { { iids: [item3.iid] } } + + it 'returns items where iids match' do + expect(items).to contain_exactly(item3, item5) + end + + context 'using NOT' do + let(:params) { { not: { iids: [item3.iid] } } } + + it 'returns items with no iids match' do + expect(items).to contain_exactly(item1, item2, item4) + end + end + end + + context 'filtering by state' do + context 'with opened' do + let(:params) { { state: 'opened' } } + + it 'returns only opened items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + + context 'with closed' do + let(:params) { { state: 'closed' } } + + it 'returns only closed items' do + expect(items).to contain_exactly(closed_item) + end + end + + context 'with all' do + let(:params) { { state: 'all' } } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5) + end + end + + context 'with invalid state' do + let(:params) { { state: 'invalid_state' } } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5) + end + end + end + + context 'filtering by created_at' do + context 'through created_after' do + let(:params) { { created_after: item3.created_at } } + + it 'returns items created on or after the given date' do + expect(items).to contain_exactly(item3) + end + end + + context 'through created_before' do + let(:params) { { created_before: item1.created_at } } + + it 'returns items created on or before the given date' do + expect(items).to contain_exactly(item1) + end + end + + context 'through created_after and created_before' do + let(:params) { { created_after: item2.created_at, created_before: item3.created_at } } + + it 'returns items created between the given dates' do + expect(items).to contain_exactly(item2, item3) + end + end + end + + context 'filtering by updated_at' do + context 'through updated_after' do + let(:params) { { updated_after: item3.updated_at } } + + it 'returns items updated on or after the given date' do + expect(items).to contain_exactly(item3) + end + end + + context 'through updated_before' do + let(:params) { { updated_before: item1.updated_at } } + + it 'returns items updated on or before the given date' do + expect(items).to contain_exactly(item1) + end + end + + context 'through updated_after and updated_before' do + let(:params) { { updated_after: item2.updated_at, updated_before: item3.updated_at } } + + it 'returns items updated between the given dates' do + expect(items).to contain_exactly(item2, item3) + end + end + end + + context 'filtering by closed_at' do + let!(:closed_item1) { create(factory, project: project1, state: :closed, closed_at: 1.week.ago) } + let!(:closed_item2) { create(factory, project: project2, state: :closed, closed_at: 1.week.from_now) } + let!(:closed_item3) { create(factory, project: project2, state: :closed, closed_at: 2.weeks.from_now) } + + context 'through closed_after' do + let(:params) { { state: :closed, closed_after: closed_item3.closed_at } } + + it 'returns items closed on or after the given date' do + expect(items).to contain_exactly(closed_item3) + end + end + + context 'through closed_before' do + let(:params) { { state: :closed, closed_before: closed_item1.closed_at } } + + it 'returns items closed on or before the given date' do + expect(items).to contain_exactly(closed_item1) + end + end + + context 'through closed_after and closed_before' do + let(:params) do + { state: :closed, closed_after: closed_item2.closed_at, closed_before: closed_item3.closed_at } + end + + it 'returns items closed between the given dates' do + expect(items).to contain_exactly(closed_item2, closed_item3) + end + end + end + + context 'filtering by reaction name' do + context 'user searches by no reaction' do + let(:params) { { my_reaction_emoji: 'None' } } + + it 'returns items that the user did not react to' do + expect(items).to contain_exactly(item2, item4, item5) + end + end + + context 'user searches by any reaction' do + let(:params) { { my_reaction_emoji: 'Any' } } + + it 'returns items that the user reacted to' do + expect(items).to contain_exactly(item1, item3) + end + end + + context 'user searches by "thumbsup" reaction' do + let(:params) { { my_reaction_emoji: 'thumbsup' } } + + it 'returns items that the user thumbsup to' do + expect(items).to contain_exactly(item1) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns items that the user did not thumbsup to' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + end + + context 'user2 searches by "thumbsup" reaction' do + let(:search_user) { user2 } + + let(:params) { { my_reaction_emoji: 'thumbsup' } } + + it 'returns items that the user2 thumbsup to' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns items that the user2 thumbsup to' do + expect(items).to contain_exactly(item3) + end + end + end + + context 'user searches by "thumbsdown" reaction' do + let(:params) { { my_reaction_emoji: 'thumbsdown' } } + + it 'returns items that the user thumbsdown to' do + expect(items).to contain_exactly(item3) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsdown' } } } + + it 'returns items that the user thumbsdown to' do + expect(items).to contain_exactly(item1, item2, item4, item5) + end + end + end + end + + context 'filtering by confidential' do + let_it_be(:confidential_item) { create(factory, project: project1, confidential: true) } + + context 'no filtering' do + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5, confidential_item) + end + end + + context 'user filters confidential items' do + let(:params) { { confidential: true } } + + it 'returns only confidential items' do + expect(items).to contain_exactly(confidential_item) + end + end + + context 'user filters only public items' do + let(:params) { { confidential: false } } + + it 'returns only public items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + end + + context 'filtering by item type' do + let_it_be(:incident_item) { create(factory, issue_type: :incident, project: project1) } + + context 'no type given' do + let(:params) { { issue_types: [] } } + + it 'returns all items' do + expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5) + end + end + + context 'incident type' do + let(:params) { { issue_types: ['incident'] } } + + it 'returns incident items' do + expect(items).to contain_exactly(incident_item) + end + end + + context 'item type' do + let(:params) { { issue_types: ['issue'] } } + + it 'returns all items with type issue' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + + context 'multiple params' do + let(:params) { { issue_types: %w(issue incident) } } + + it 'returns all items' do + expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5) + end + end + + context 'without array' do + let(:params) { { issue_types: 'incident' } } + + it 'returns incident items' do + expect(items).to contain_exactly(incident_item) + end + end + + context 'invalid params' do + let(:params) { { issue_types: ['nonsense'] } } + + it 'returns no items' do + expect(items).to eq(items_model.none) + end + end + end + + context 'filtering by crm contact' do + let_it_be(:contact1) { create(:contact, group: group) } + let_it_be(:contact2) { create(:contact, group: group) } + + let_it_be(:contact1_item1) { create(factory, project: project1) } + let_it_be(:contact1_item2) { create(factory, project: project1) } + let_it_be(:contact2_item1) { create(factory, project: project1) } + + let(:params) { { crm_contact_id: contact1.id } } + + it 'returns for that contact' do + create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1) + create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1) + create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2) + + expect(items).to contain_exactly(contact1_item1, contact1_item2) + end + end + + context 'filtering by crm organization' do + let_it_be(:organization) { create(:organization, group: group) } + let_it_be(:contact1) { create(:contact, group: group, organization: organization) } + let_it_be(:contact2) { create(:contact, group: group, organization: organization) } + + let_it_be(:contact1_item1) { create(factory, project: project1) } + let_it_be(:contact1_item2) { create(factory, project: project1) } + let_it_be(:contact2_item1) { create(factory, project: project1) } + + let(:params) { { crm_organization_id: organization.id } } + + it 'returns for that contact' do + create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1) + create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1) + create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2) + + expect(items).to contain_exactly(contact1_item1, contact1_item2, contact2_item1) + end + end + + context 'when the user is unauthorized' do + let(:search_user) { nil } + + it 'returns no results' do + expect(items).to be_empty + end + end + + context 'when the user can see some, but not all, items' do + let(:search_user) { user2 } + + it 'returns only items they can see' do + expect(items).to contain_exactly(item2, item3) + end + end + + it 'finds items user can access due to group' do + group = create(:group) + project = create(:project, group: group) + item = create(factory, project: project) + group.add_user(user, :owner) + + expect(items).to include(item) + end + end + + context 'personal scope' do + let(:scope) { 'assigned_to_me' } + + it 'returns item assigned to the user' do + expect(items).to contain_exactly(item1, item2, item5) + end + + context 'filtering by project' do + let(:params) { { project_id: project1.id } } + + it 'returns items assigned to the user in that project' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'when project restricts items' do + let(:scope) { nil } + + it "doesn't return team-only items to non team members" do + project = create(:project, :public, :issues_private) + item = create(factory, project: project) + + expect(items).not_to include(item) + end + + it "doesn't return items if feature disabled" do + [project1, project2, project3].each do |project| + project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED) + end + + expect(items.count).to eq 0 + end + end + + context 'external authorization' do + it_behaves_like 'a finder with external authorization service' do + let!(:subject) { create(factory, project: project) } + let(:project_params) { { project_id: project.id } } + end + end + + context 'filtering by due date' do + let_it_be(:item_due_today) { create(factory, project: project1, due_date: Date.current) } + let_it_be(:item_due_tomorrow) { create(factory, project: project1, due_date: 1.day.from_now) } + let_it_be(:item_overdue) { create(factory, project: project1, due_date: 2.days.ago) } + let_it_be(:item_due_soon) { create(factory, project: project1, due_date: 2.days.from_now) } + + let(:scope) { 'all' } + let(:base_params) { { project_id: project1.id } } + + context 'with param set to no due date' do + let(:params) { base_params.merge(due_date: items_model::NoDueDate.name) } + + it 'returns items with no due date' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'with param set to any due date' do + let(:params) { base_params.merge(due_date: items_model::AnyDueDate.name) } + + it 'returns items with any due date' do + expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon) + end + end + + context 'with param set to due today' do + let(:params) { base_params.merge(due_date: items_model::DueToday.name) } + + it 'returns items due today' do + expect(items).to contain_exactly(item_due_today) + end + end + + context 'with param set to due tomorrow' do + let(:params) { base_params.merge(due_date: items_model::DueTomorrow.name) } + + it 'returns items due today' do + expect(items).to contain_exactly(item_due_tomorrow) + end + end + + context 'with param set to overdue' do + let(:params) { base_params.merge(due_date: items_model::Overdue.name) } + + it 'returns overdue items' do + expect(items).to contain_exactly(item_overdue) + end + end + + context 'with param set to next month and previous two weeks' do + let(:params) { base_params.merge(due_date: items_model::DueNextMonthAndPreviousTwoWeeks.name) } + + it 'returns items due in the previous two weeks and next month' do + expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon) + end + end + + context 'with invalid param' do + let(:params) { base_params.merge(due_date: 'foo') } + + it 'returns no items' do + expect(items).to be_empty + end + end + end + end + + describe '#row_count', :request_store do + let_it_be(:admin) { create(:admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns the number of rows for the default state' do + finder = described_class.new(admin) + + expect(finder.row_count).to eq(5) + end + + it 'returns the number of rows for a given state' do + finder = described_class.new(admin, state: 'closed') + + expect(finder.row_count).to be_zero + end + end + + context 'when admin mode is disabled' do + it 'returns no rows' do + finder = described_class.new(admin) + + expect(finder.row_count).to be_zero + end + end + + it 'returns -1 if the query times out' do + finder = described_class.new(admin) + + expect_next_instance_of(described_class) do |subfinder| + expect(subfinder).to receive(:execute).and_raise(ActiveRecord::QueryCanceled) + end + + expect(finder.row_count).to eq(-1) + end + end + + describe '#with_confidentiality_access_check' do + let(:guest) { create(:user) } + + let_it_be(:authorized_user) { create(:user) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:project) { create(:project, namespace: authorized_user.namespace) } + let_it_be(:public_item) { create(factory, project: project) } + let_it_be(:confidential_item) { create(factory, project: project, confidential: true) } + let_it_be(:hidden_item) { create(factory, project: project, author: banned_user) } + + shared_examples 'returns public, does not return hidden or confidential' do + it 'returns only public items' do + expect(subject).to include(public_item) + expect(subject).not_to include(confidential_item, hidden_item) + end + end + + shared_examples 'returns public and confidential, does not return hidden' do + it 'returns only public and confidential items' do + expect(subject).to include(public_item, confidential_item) + expect(subject).not_to include(hidden_item) + end + end + + shared_examples 'returns public and hidden, does not return confidential' do + it 'returns only public and hidden items' do + expect(subject).to include(public_item, hidden_item) + expect(subject).not_to include(confidential_item) + end + end + + shared_examples 'returns public, confidential, and hidden' do + it 'returns all items' do + expect(subject).to include(public_item, confidential_item, hidden_item) + end + end + + context 'when no project filter is given' do + let(:params) { {} } + + context 'for an anonymous user' do + subject { described_class.new(nil, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a user without project membership' do + subject { described_class.new(user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a guest user' do + subject { described_class.new(guest, params).with_confidentiality_access_check } + + before do + project.add_guest(guest) + end + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a project member with access to view confidential items' do + subject { described_class.new(authorized_user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public and confidential, does not return hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + end + + context 'for an admin' do + let(:admin_user) { create(:user, :admin) } + + subject { described_class.new(admin_user, params).with_confidentiality_access_check } + + context 'when admin mode is enabled', :enable_admin_mode do + it_behaves_like 'returns public, confidential, and hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + end + + context 'when admin mode is disabled' do + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + end + end + + context 'when searching within a specific project' do + let(:params) { { project_id: project.id } } + + context 'for an anonymous user' do + subject { described_class.new(nil, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + subject + end + end + + context 'for a user without project membership' do + subject { described_class.new(user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + + context 'for a guest user' do + subject { described_class.new(guest, params).with_confidentiality_access_check } + + before do + project.add_guest(guest) + end + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + + context 'for a project member with access to view confidential items' do + subject { described_class.new(authorized_user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public and confidential, does not return hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + + subject + end + end + + context 'for an admin' do + let(:admin_user) { create(:user, :admin) } + + subject { described_class.new(admin_user, params).with_confidentiality_access_check } + + context 'when admin mode is enabled', :enable_admin_mode do + it_behaves_like 'returns public, confidential, and hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + + subject + end + end + + context 'when admin mode is disabled' do + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + end + end + end + + describe '#use_cte_for_search?' do + let(:finder) { described_class.new(nil, params) } + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the force_cte param is falsey' do + let(:params) { { search: '日本語' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when a non-simple sort is given' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'popularity' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + context "uses group search optimization" do + let(:params) { { search: '日本語', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context "uses project search optimization" do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context 'with simple sort' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'updated_desc' } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context 'with simple sort as a symbol' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: :updated_desc } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + end + end + + describe '#parent_param=' do + using RSpec::Parameterized::TableSyntax + + let(:finder) { described_class.new(nil) } + + subject { finder.parent_param = obj } + + where(:klass, :param) do + :Project | :project_id + :Group | :group_id + end + + with_them do + let(:obj) { Object.const_get(klass, false).new } + + it 'sets the params' do + subject + + expect(finder.params[param]).to eq(obj) + end + end + + context 'unexpected parent' do + let(:obj) { MergeRequest.new } + + it 'raises an error' do + expect { subject }.to raise_error('Unexpected parent: MergeRequest') + end + end + end +end diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb index 8e9e22f4359..110706c730b 100644 --- a/spec/support/shared_examples/graphql/members_shared_examples.rb +++ b/spec/support/shared_examples/graphql/members_shared_examples.rb @@ -39,7 +39,8 @@ RSpec.shared_examples 'querying members with a group' do let(:base_args) { { relations: described_class.arguments['relations'].default_value } } subject do - resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: user_4 }) + resolve(described_class, obj: resource, args: base_args.merge(args), + ctx: { current_user: user_4 }, arg_style: :internal) end describe '#resolve' do @@ -73,7 +74,8 @@ RSpec.shared_examples 'querying members with a group' do let_it_be(:other_user) { create(:user) } subject do - resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: other_user }) + resolve(described_class, obj: resource, args: base_args.merge(args), + ctx: { current_user: other_user }, arg_style: :internal) end it 'generates an error' do diff --git a/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb index b989dbc6524..cd591248ff6 100644 --- a/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb @@ -21,6 +21,7 @@ RSpec.shared_examples 'creating an incident timeline event' do expect(timeline_event.occurred_at.to_s).to eq(expected_timeline_event.occurred_at) expect(timeline_event.incident).to eq(expected_timeline_event.incident) expect(timeline_event.author).to eq(expected_timeline_event.author) + expect(timeline_event.editable).to eq(expected_timeline_event.editable) end end diff --git a/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb index 21260e4d954..dfb8ce64391 100644 --- a/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb @@ -71,7 +71,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns an array of errors' do - expect(result).to match( + expect(result).to include( branch: be_nil, success_path: be_nil, errors: match_array([error_message]) @@ -92,7 +92,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns a success path' do - expect(result).to match( + expect(result).to include( branch: branch, success_path: success_path, errors: [] @@ -108,7 +108,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns an array of errors' do - expect(result).to match( + expect(result).to include( branch: be_nil, success_path: be_nil, errors: match_array([error]) diff --git a/spec/support/shared_examples/graphql/projects/merge_request_n_plus_one_query_examples.rb b/spec/support/shared_examples/graphql/n_plus_one_query_examples.rb index 738edd43c92..738edd43c92 100644 --- a/spec/support/shared_examples/graphql/projects/merge_request_n_plus_one_query_examples.rb +++ b/spec/support/shared_examples/graphql/n_plus_one_query_examples.rb diff --git a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb index da8562161e7..3017f62a7c9 100644 --- a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb +++ b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb @@ -24,7 +24,7 @@ RSpec.shared_examples 'group and projects packages resolver' do create(:maven_package, name: 'baz', project: project, created_at: 1.minute.ago, version: nil) end - [:created_desc, :name_desc, :version_desc, :type_asc].each do |order| + %w[CREATED_DESC NAME_DESC VERSION_DESC TYPE_ASC].each do |order| context "#{order}" do let(:args) { { sort: order } } @@ -32,7 +32,7 @@ RSpec.shared_examples 'group and projects packages resolver' do end end - [:created_asc, :name_asc, :version_asc, :type_desc].each do |order| + %w[CREATED_ASC NAME_ASC VERSION_ASC TYPE_DESC].each do |order| context "#{order}" do let(:args) { { sort: order } } @@ -41,25 +41,25 @@ RSpec.shared_examples 'group and projects packages resolver' do end context 'filter by package_name' do - let(:args) { { package_name: 'bar', sort: :created_desc } } + let(:args) { { package_name: 'bar', sort: 'CREATED_DESC' } } it { is_expected.to eq([conan_package]) } end context 'filter by package_type' do - let(:args) { { package_type: 'conan', sort: :created_desc } } + let(:args) { { package_type: 'conan', sort: 'CREATED_DESC' } } it { is_expected.to eq([conan_package]) } end context 'filter by status' do - let(:args) { { status: 'error', sort: :created_desc } } + let(:args) { { status: 'error', sort: 'CREATED_DESC' } } it { is_expected.to eq([maven_package]) } end context 'include_versionless' do - let(:args) { { include_versionless: true, sort: :created_desc } } + let(:args) { { include_versionless: true, sort: 'CREATED_DESC' } } it { is_expected.to include(repository3) } end diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb index cf9c36fafe8..7fd54408b11 100644 --- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb @@ -53,18 +53,20 @@ RSpec.shared_examples 'Gitlab-style deprecations' do it 'adds information about the replacement if provided' do deprecable = subject(deprecated: { milestone: '1.10', reason: :renamed, replacement: 'Foo.bar' }) - expect(deprecable.deprecation_reason).to include 'Please use `Foo.bar`' + expect(deprecable.deprecation_reason).to include('Please use `Foo.bar`') end it 'supports named reasons: renamed' do deprecable = subject(deprecated: { milestone: '1.10', reason: :renamed }) - expect(deprecable.deprecation_reason).to include 'This was renamed.' + expect(deprecable.deprecation_reason).to eq('This was renamed. Deprecated in 1.10.') end it 'supports named reasons: alpha' do deprecable = subject(deprecated: { milestone: '1.10', reason: :alpha }) - expect(deprecable.deprecation_reason).to include 'This feature is in Alpha' + expect(deprecable.deprecation_reason).to eq( + 'This feature is in Alpha. It can be changed or removed at any time. Introduced in 1.10.' + ) end end diff --git a/spec/support/shared_examples/integrations/integration_settings_form.rb b/spec/support/shared_examples/integrations/integration_settings_form.rb index d8a46180796..dfe5a071f91 100644 --- a/spec/support/shared_examples/integrations/integration_settings_form.rb +++ b/spec/support/shared_examples/integrations/integration_settings_form.rb @@ -20,10 +20,18 @@ RSpec.shared_examples 'integration settings form' do "#{integration.title} field #{field_name} not present" end + sections = integration.sections events = parse_json(trigger_events_for_integration(integration)) + events.each do |trigger| - expect(page).to have_field(trigger[:title], type: 'checkbox', wait: 0), - "#{integration.title} field #{title} checkbox not present" + trigger_title = if sections.any? { |s| s[:type] == 'trigger' } + trigger_event_title(trigger[:name]) + else + trigger[:title] + end + + expect(page).to have_field(trigger_title, type: 'checkbox', wait: 0), + "#{integration.title} field #{trigger_title} checkbox not present" end end end @@ -35,4 +43,20 @@ RSpec.shared_examples 'integration settings form' do def parse_json(json) Gitlab::Json.parse(json, symbolize_names: true) end + + def trigger_event_title(name) + # Should match `integrationTriggerEventTitles` in app/assets/javascripts/integrations/constants.js + event_titles = { + push_events: s_('IntegrationEvents|A push is made to the repository'), + issues_events: s_('IntegrationEvents|IntegrationEvents|An issue is created, updated, or closed'), + confidential_issues_events: s_('IntegrationEvents|A confidential issue is created, updated, or closed'), + merge_requests_events: s_('IntegrationEvents|A merge request is created, updated, or merged'), + note_events: s_('IntegrationEvents|A comment is added on an issue'), + confidential_note_events: s_('IntegrationEvents|A comment is added on a confidential issue'), + tag_push_events: s_('IntegrationEvents|A tag is pushed to the repository'), + pipeline_events: s_('IntegrationEvents|A pipeline status changes'), + wiki_page_events: s_('IntegrationEvents|A wiki page is created or updated') + }.with_indifferent_access + event_titles[name] + end end diff --git a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb index e886ec65b02..284c129221b 100644 --- a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb @@ -834,8 +834,8 @@ RSpec.shared_examples 'trace with enabled live trace feature' do end end - describe '#live_trace_exist?' do - subject { trace.live_trace_exist? } + describe '#live?' do + subject { trace.live? } context 'when trace does not exist' do it { is_expected.to be_falsy } diff --git a/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb index 67d739b79ab..d14216ec5ff 100644 --- a/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb @@ -22,7 +22,7 @@ RSpec.shared_context 'reconfigures connection stack' do |db_config_name| end end - def validate_connections! + def validate_connections_stack! model_connections = Gitlab::Database.database_base_models.to_h do |db_config_name, model_class| [model_class, Gitlab::Database.db_config_name(model_class.connection)] end diff --git a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb index 4fc15cacab4..db2f2f2d0f0 100644 --- a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb @@ -11,6 +11,8 @@ RSpec.shared_examples 'subscribes to event' do ::Gitlab::EventStore.publish(event) end + + it_behaves_like 'an idempotent worker' end def consume_event(subscriber:, event:) diff --git a/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb new file mode 100644 index 00000000000..a5e4df1c272 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'multi store feature flags' do |use_primary_and_secondary_stores, use_primary_store_as_default| + context "with feature flag :#{use_primary_and_secondary_stores} is enabled" do + before do + stub_feature_flags(use_primary_and_secondary_stores => true) + end + + it 'multi store is enabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_and_secondary_stores?).to be true + end + end + end + + context "with feature flag :#{use_primary_and_secondary_stores} is disabled" do + before do + stub_feature_flags(use_primary_and_secondary_stores => false) + end + + it 'multi store is disabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_and_secondary_stores?).to be false + end + end + end + + context "with feature flag :#{use_primary_store_as_default} is enabled" do + before do + stub_feature_flags(use_primary_store_as_default => true) + end + + it 'primary store is enabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_store_as_default?).to be true + end + end + end + + context "with feature flag :#{use_primary_store_as_default} is disabled" do + before do + stub_feature_flags(use_primary_store_as_default => false) + end + + it 'primary store is disabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_store_as_default?).to be false + end + end + end +end diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb index 74ec6474e80..6e7d04d3cba 100644 --- a/spec/support/shared_examples/models/application_setting_shared_examples.rb +++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb @@ -238,8 +238,16 @@ RSpec.shared_examples 'application settings examples' do end describe '#allowed_key_types' do - it 'includes all key types by default' do - expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types) + context 'in non-FIPS mode', fips_mode: false do + it 'includes all key types by default' do + expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types) + end + end + + context 'in FIPS mode', :fips_mode do + it 'excludes DSA from supported key types' do + expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types - %i(dsa)) + end end it 'excludes disabled key types' do diff --git a/spec/support/shared_examples/models/commit_signature_shared_examples.rb b/spec/support/shared_examples/models/commit_signature_shared_examples.rb new file mode 100644 index 00000000000..56d5c1da3af --- /dev/null +++ b/spec/support/shared_examples/models/commit_signature_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'commit signature' do + describe 'associations' do + it { is_expected.to belong_to(:project).required } + end + + describe 'validation' do + subject { described_class.new } + + it { is_expected.to validate_presence_of(:commit_sha) } + it { is_expected.to validate_presence_of(:project_id) } + end + + describe '.safe_create!' do + it 'finds a signature by commit sha if it existed' do + signature + + expect(described_class.safe_create!(commit_sha: commit_sha)).to eq(signature) + end + + it 'creates a new signature if it was not found' do + expect { described_class.safe_create!(attributes) }.to change { described_class.count }.by(1) + end + + it 'assigns the correct attributes when creating' do + signature = described_class.safe_create!(attributes) + + expect(signature).to have_attributes(attributes) + end + + it 'does not raise an error in case of a race condition' do + expect(described_class).to receive(:find_by).and_return(nil, instance_double(described_class, persisted?: true)) + + expect(described_class).to receive(:create).and_raise(ActiveRecord::RecordNotUnique) + allow(described_class).to receive(:create).and_call_original + + described_class.safe_create!(attributes) + end + end + + describe '#commit' do + it 'fetches the commit through the project' do + expect_next_instance_of(Project) do |instance| + expect(instance).to receive(:commit).with(commit_sha).and_return(commit) + end + + signature.commit + end + end +end diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb index 0ff0895b861..3d393e6dcb5 100644 --- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb @@ -1,6 +1,30 @@ # frozen_string_literal: true RSpec.shared_examples 'includes Limitable concern' do + describe '#exceeds_limits?' do + let(:plan_limits) { create(:plan_limits, :default_plan) } + + context 'without plan limits configured' do + it { expect(subject.exceeds_limits?).to eq false } + end + + context 'without plan limits configured' do + before do + plan_limits.update!(subject.class.limit_name => 1) + end + + it { expect(subject.exceeds_limits?).to eq false } + + context 'with an existing model' do + before do + subject.clone.save! + end + + it { expect(subject.exceeds_limits?).to eq true } + end + end + end + describe 'validations' do let(:plan_limits) { create(:plan_limits, :default_plan) } diff --git a/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb b/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb new file mode 100644 index 00000000000..211beb5b32f --- /dev/null +++ b/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples Integrations::BaseDataFields do + subject(:model) { described_class.new } + + describe 'associations' do + it { is_expected.to belong_to :integration } + end + + describe '#activated?' do + subject(:activated?) { model.activated? } + + context 'with integration' do + let(:integration) { instance_spy(Integration, activated?: activated) } + + before do + allow(model).to receive(:integration).and_return(integration) + end + + context 'with value set to false' do + let(:activated) { false } + + it { is_expected.to eq(false) } + end + + context 'with value set to true' do + let(:activated) { true } + + it { is_expected.to eq(true) } + end + end + + context 'without integration' do + before do + allow(model).to receive(:integration).and_return(nil) + end + + it { is_expected.to eq(false) } + end + end + + describe '#to_database_hash' do + it 'does not include certain attributes' do + hash = model.to_database_hash + + expect(hash.keys).not_to include('id', 'service_id', 'integration_id', 'created_at', 'updated_at') + end + end +end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index e293d10964b..75fff11cecd 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -80,7 +80,7 @@ RSpec.shared_examples_for "member creation" do let_it_be(:admin) { create(:admin) } it 'returns a Member object', :aggregate_failures do - member = described_class.new(source, user, :maintainer).execute + member = described_class.add_user(source, user, :maintainer) expect(member).to be_a member_type expect(member).to be_persisted @@ -99,7 +99,7 @@ RSpec.shared_examples_for "member creation" do end it 'does not update the member' do - member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + member = described_class.add_user(source, project_bot, :maintainer, current_user: user) expect(source.users.reload).to include(project_bot) expect(member).to be_persisted @@ -110,7 +110,7 @@ RSpec.shared_examples_for "member creation" do context 'when project_bot is not already a member' do it 'adds the member' do - member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + member = described_class.add_user(source, project_bot, :maintainer, current_user: user) expect(source.users.reload).to include(project_bot) expect(member).to be_persisted @@ -120,7 +120,7 @@ RSpec.shared_examples_for "member creation" do context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do it 'sets members.created_by to the given admin current_user' do - member = described_class.new(source, user, :maintainer, current_user: admin).execute + member = described_class.add_user(source, user, :maintainer, current_user: admin) expect(member).to be_persisted expect(source.users.reload).to include(user) @@ -130,7 +130,7 @@ RSpec.shared_examples_for "member creation" do context 'when admin mode is disabled' do it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do - member = described_class.new(source, user, :maintainer, current_user: admin).execute + member = described_class.add_user(source, user, :maintainer, current_user: admin) expect(member).not_to be_persisted expect(source.users.reload).not_to include(user) @@ -139,7 +139,7 @@ RSpec.shared_examples_for "member creation" do end it 'sets members.expires_at to the given expires_at' do - member = described_class.new(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)).execute + member = described_class.add_user(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)) expect(member.expires_at).to eq(Date.new(2016, 9, 22)) end @@ -148,7 +148,7 @@ RSpec.shared_examples_for "member creation" do it "accepts the :#{sym_key} symbol as access level", :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user.id, sym_key).execute + member = described_class.add_user(source, user.id, sym_key) expect(member.access_level).to eq(int_access_level) expect(source.users.reload).to include(user) @@ -157,7 +157,7 @@ RSpec.shared_examples_for "member creation" do it "accepts the #{int_access_level} integer as access level", :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user.id, int_access_level).execute + member = described_class.add_user(source, user.id, int_access_level) expect(member.access_level).to eq(int_access_level) expect(source.users.reload).to include(user) @@ -169,7 +169,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user.id, :maintainer).execute + described_class.add_user(source, user.id, :maintainer) expect(source.users.reload).to include(user) end @@ -179,7 +179,7 @@ RSpec.shared_examples_for "member creation" do it 'does not add the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, non_existing_record_id, :maintainer).execute + described_class.add_user(source, non_existing_record_id, :maintainer) expect(source.users.reload).not_to include(user) end @@ -189,7 +189,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) expect(source.users.reload).to include(user) end @@ -200,12 +200,12 @@ RSpec.shared_examples_for "member creation" do source.request_access(user) end - it 'adds the requester as a member', :aggregate_failures do + it 'does not add the requester as a regular member', :aggregate_failures do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy expect do - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) end.to raise_error(Gitlab::Access::AccessDeniedError) expect(source.users.reload).not_to include(user) @@ -217,7 +217,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user.email, :maintainer).execute + described_class.add_user(source, user.email, :maintainer) expect(source.users.reload).to include(user) end @@ -227,7 +227,7 @@ RSpec.shared_examples_for "member creation" do it 'creates an invited member' do expect(source.users).not_to include(user) - described_class.new(source, 'user@example.com', :maintainer).execute + described_class.add_user(source, 'user@example.com', :maintainer) expect(source.members.invite.pluck(:invite_email)).to include('user@example.com') end @@ -237,7 +237,7 @@ RSpec.shared_examples_for "member creation" do it 'creates an invited member', :aggregate_failures do email_starting_with_number = "#{user.id}_email@example.com" - described_class.new(source, email_starting_with_number, :maintainer).execute + described_class.add_user(source, email_starting_with_number, :maintainer) expect(source.members.invite.pluck(:invite_email)).to include(email_starting_with_number) expect(source.users.reload).not_to include(user) @@ -249,7 +249,7 @@ RSpec.shared_examples_for "member creation" do it 'creates the member' do expect(source.users).not_to include(user) - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.users.reload).to include(user) end @@ -263,7 +263,7 @@ RSpec.shared_examples_for "member creation" do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.users.reload).to include(user) expect(source.requesters.reload.exists?(user_id: user)).to be_falsy @@ -275,7 +275,7 @@ RSpec.shared_examples_for "member creation" do it 'does not create the member', :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user, :maintainer, current_user: user).execute + member = described_class.add_user(source, user, :maintainer, current_user: user) expect(source.users.reload).not_to include(user) expect(member).not_to be_persisted @@ -290,7 +290,7 @@ RSpec.shared_examples_for "member creation" do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy - described_class.new(source, user, :maintainer, current_user: user).execute + described_class.add_user(source, user, :maintainer, current_user: user) expect(source.users.reload).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy @@ -307,7 +307,7 @@ RSpec.shared_examples_for "member creation" do it 'updates the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER) end @@ -317,7 +317,7 @@ RSpec.shared_examples_for "member creation" do it 'updates the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER) end @@ -327,221 +327,194 @@ RSpec.shared_examples_for "member creation" do it 'does not update the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer, current_user: user).execute + described_class.add_user(source, user, :maintainer, current_user: user) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::DEVELOPER) end end end +end - context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do - let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } - - it 'creates a member_task with the correct attributes', :aggregate_failures do - described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute +RSpec.shared_examples_for "bulk member creation" do + let_it_be(:admin) { create(:admin) } + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } - member = source.members.last + context 'when current user does not have permission' do + it 'does not succeed' do + # maintainers cannot add owners + source.add_maintainer(user) - expect(member.tasks_to_be_done).to match_array([:ci, :code]) - expect(member.member_task.project).to eq(task_project) + expect(described_class.add_users(source, [user1, user2], :owner, current_user: user)).to be_empty end + end - context 'with an already existing member' do - before do - source.add_user(user, :developer) - end - - it 'does not update tasks to be done if tasks already exist', :aggregate_failures do - member = source.members.find_by(user_id: user.id) - create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) - - expect do - described_class.new(source, - user, - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id).execute - end.not_to change(MemberTask, :count) + it 'returns Member objects' do + members = described_class.add_users(source, [user1, user2], :maintainer) - member.reset - expect(member.tasks_to_be_done).to match_array([:code, :ci]) - expect(member.member_task.project).to eq(task_project) - end + expect(members.map(&:user)).to contain_exactly(user1, user2) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end - it 'adds tasks to be done if they do not exist', :aggregate_failures do - expect do - described_class.new(source, - user, - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id).execute - end.to change(MemberTask, :count).by(1) + it 'returns an empty array' do + members = described_class.add_users(source, [], :maintainer) - member = source.members.find_by(user_id: user.id) - expect(member.tasks_to_be_done).to match_array([:issues]) - expect(member.member_task.project).to eq(task_project) - end - end + expect(members).to be_a Array + expect(members).to be_empty end -end -RSpec.shared_examples_for "bulk member creation" do - let_it_be(:user) { create(:user) } - let_it_be(:admin) { create(:admin) } + it 'supports different formats' do + list = ['joe@local.test', admin, user1.id, user2.id.to_s] - describe '#execute' do - it 'raises an error when exiting_members is not passed in the args hash' do - expect do - described_class.new(source, user, :maintainer, current_user: user).execute - end.to raise_error(ArgumentError, 'existing_members must be included in the args hash') - end - end + members = described_class.add_users(source, list, :maintainer) - describe '.add_users', :aggregate_failures do - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } + expect(members.size).to eq(4) + expect(members.first).to be_invite + end - it 'returns a Member objects' do - members = described_class.add_users(source, [user1, user2], :maintainer) + context 'with de-duplication' do + it 'has the same user by id and user' do + members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer) expect(members.map(&:user)).to contain_exactly(user1, user2) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) end - it 'returns an empty array' do - members = described_class.add_users(source, [], :maintainer) + it 'has the same user sent more than once' do + members = described_class.add_users(source, [user1, user1], :maintainer) - expect(members).to be_a Array - expect(members).to be_empty + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) end + end - it 'supports different formats' do - list = ['joe@local.test', admin, user1.id, user2.id.to_s] + it 'with the same user sent more than once by user and by email' do + members = described_class.add_users(source, [user1, user1.email], :maintainer) - members = described_class.add_users(source, list, :maintainer) + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end - expect(members.size).to eq(4) - expect(members.first).to be_invite - end + it 'with the same user sent more than once by user id and by email' do + members = described_class.add_users(source, [user1.id, user1.email], :maintainer) - context 'with de-duplication' do - it 'has the same user by id and user' do - members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + + context 'when a member already exists' do + before do + source.add_user(user1, :developer) + end + it 'has the same user sent more than once with the member already existing' do + expect do + members = described_class.add_users(source, [user1, user1, user2], :maintainer) expect(members.map(&:user)).to contain_exactly(user1, user2) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) - end + end.to change { Member.count }.by(1) + end - it 'has the same user sent more than once' do - members = described_class.add_users(source, [user1, user1], :maintainer) + it 'supports existing users as expected with user_ids passed' do + user3 = create(:user) - expect(members.map(&:user)).to contain_exactly(user1) + expect do + members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1, user2, user3) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) - end + end.to change { Member.count }.by(2) end - it 'with the same user sent more than once by user and by email' do - members = described_class.add_users(source, [user1, user1.email], :maintainer) + it 'supports existing users as expected without user ids passed' do + user3 = create(:user) - expect(members.map(&:user)).to contain_exactly(user1) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) + expect do + members = described_class.add_users(source, [user1, user2, user3], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1, user2, user3) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end.to change { Member.count }.by(2) end + end - it 'with the same user sent more than once by user id and by email' do - members = described_class.add_users(source, [user1.id, user1.email], :maintainer) + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } - expect(members.map(&:user)).to contain_exactly(user1) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) + it 'creates a member_task with the correct attributes', :aggregate_failures do + members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id) + member = members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(task_project) end - context 'when a member already exists' do + context 'with an already existing member' do before do source.add_user(user1, :developer) end - it 'has the same user sent more than once with the member already existing' do - expect do - members = described_class.add_users(source, [user1, user1, user2], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(1) - end - - it 'supports existing users as expected with user_ids passed' do - user3 = create(:user) + it 'does not update tasks to be done if tasks already exist', :aggregate_failures do + member = source.members.find_by(user_id: user1.id) + create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) expect do - members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2, user3) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(2) - end + described_class.add_users(source, + [user1.id], + :developer, + tasks_to_be_done: %w(issues), + tasks_project_id: task_project.id) + end.not_to change(MemberTask, :count) - it 'supports existing users as expected without user ids passed' do - user3 = create(:user) + member.reset + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project).to eq(task_project) + end + it 'adds tasks to be done if they do not exist', :aggregate_failures do expect do - members = described_class.add_users(source, [user1, user2, user3], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2, user3) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(2) + described_class.add_users(source, + [user1.id], + :developer, + tasks_to_be_done: %w(issues), + tasks_project_id: task_project.id) + end.to change(MemberTask, :count).by(1) + + member = source.members.find_by(user_id: user1.id) + expect(member.tasks_to_be_done).to match_array([:issues]) + expect(member.member_task.project).to eq(task_project) end end + end +end + +RSpec.shared_examples 'owner management' do + describe '.cannot_manage_owners?' do + subject { described_class.cannot_manage_owners?(source, user) } - context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do - let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } + context 'when maintainer' do + before do + source.add_maintainer(user) + end - it 'creates a member_task with the correct attributes', :aggregate_failures do - members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id) - member = members.last + it 'cannot manage owners' do + expect(subject).to be_truthy + end + end - expect(member.tasks_to_be_done).to match_array([:ci, :code]) - expect(member.member_task.project).to eq(task_project) + context 'when owner' do + before do + source.add_owner(user) end - context 'with an already existing member' do - before do - source.add_user(user1, :developer) - end - - it 'does not update tasks to be done if tasks already exist', :aggregate_failures do - member = source.members.find_by(user_id: user1.id) - create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) - - expect do - described_class.add_users(source, - [user1.id], - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id) - end.not_to change(MemberTask, :count) - - member.reset - expect(member.tasks_to_be_done).to match_array([:code, :ci]) - expect(member.member_task.project).to eq(task_project) - end - - it 'adds tasks to be done if they do not exist', :aggregate_failures do - expect do - described_class.add_users(source, - [user1.id], - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id) - end.to change(MemberTask, :count).by(1) - - member = source.members.find_by(user_id: user1.id) - expect(member.tasks_to_be_done).to match_array([:issues]) - expect(member.member_task.project).to eq(task_project) - end + it 'can manage owners' do + expect(subject).to be_falsey end end end diff --git a/spec/support/shared_examples/models/members_notifications_shared_example.rb b/spec/support/shared_examples/models/members_notifications_shared_example.rb index 04af3935d15..75eed0203a7 100644 --- a/spec/support/shared_examples/models/members_notifications_shared_example.rb +++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb @@ -33,6 +33,18 @@ RSpec.shared_examples 'members notifications' do |entity_type| end end + describe '#after_commit' do + context 'on creation of a member requesting access' do + let(:member) { build(:"#{entity_type}_member", :access_request) } + + it "calls NotificationService.new_access_request" do + expect(notification_service).to receive(:new_access_request).with(member) + + member.save! + end + end + end + describe '#accept_request' do let(:member) { create(:"#{entity_type}_member", :access_request) } diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index 6f17231a040..604c57768fe 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -540,14 +540,6 @@ RSpec.shared_examples 'wiki model' do end end end - - context 'when feature flag :gitaly_replace_wiki_create_page is disabled' do - before do - stub_feature_flags(gitaly_replace_wiki_create_page: false) - end - - it_behaves_like 'create_page tests' - end end describe '#update_page' do diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index f1ace9878e9..45da1d382c1 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -238,6 +238,12 @@ RSpec.shared_examples 'namespace traversal scopes' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants(include_self: false) } it { is_expected.to contain_exactly(deep_nested_group_1, deep_nested_group_2) } + + context 'with duplicate descendants' do + subject { described_class.where(id: [group_1, nested_group_1]).self_and_descendants(include_self: false) } + + it { is_expected.to contain_exactly(nested_group_1, deep_nested_group_1) } + end end context 'with offset and limit' do @@ -267,6 +273,14 @@ RSpec.shared_examples 'namespace traversal scopes' do include_examples '.self_and_descendants' end + + context 'with linear_scopes_superset feature flag disabled' do + before do + stub_feature_flags(linear_scopes_superset: false) + end + + include_examples '.self_and_descendants' + end end shared_examples '.self_and_descendant_ids' do @@ -310,6 +324,14 @@ RSpec.shared_examples 'namespace traversal scopes' do include_examples '.self_and_descendant_ids' end + + context 'with linear_scopes_superset feature flag disabled' do + before do + stub_feature_flags(linear_scopes_superset: false) + end + + include_examples '.self_and_descendant_ids' + end end shared_examples '.self_and_hierarchy' do diff --git a/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb new file mode 100644 index 00000000000..7c3f4781472 --- /dev/null +++ b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'preventing request because of ongoing project stats refresh' do |entrypoint| + before do + create(:project_build_artifacts_size_refresh, :pending, project: project) + end + + it 'logs about the rejected request' do + expect(Gitlab::ProjectStatsRefreshConflictsLogger) + .to receive(:warn_request_rejected_during_stats_refresh) + .with(project.id) + + make_request + end + + it 'returns 409 error' do + make_request + + expect(response).to have_gitlab_http_status(:conflict) + end +end diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index aff086d1ba3..795545e4ad1 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -124,6 +124,23 @@ RSpec.shared_examples 'PyPI package versions' do |user_type, status, add_member end end +RSpec.shared_examples 'PyPI package index' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + group.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it 'returns the package index' do + subject + + expect(response.body).to match(package.name) + end + + it_behaves_like 'returning response status', status + end +end + RSpec.shared_examples 'PyPI package download' do |user_type, status, add_member = true| context "for user type #{user_type}" do before do @@ -259,6 +276,45 @@ RSpec.shared_examples 'pypi simple API endpoint' do end end +RSpec.shared_examples 'pypi simple index API endpoint' do + using RSpec::Parameterized::TableSyntax + + context 'with valid project' do + where(:visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + :public | :developer | true | true | 'PyPI package index' | :success + :public | :guest | true | true | 'PyPI package index' | :success + :public | :developer | true | false | 'PyPI package index' | :success + :public | :guest | true | false | 'PyPI package index' | :success + :public | :developer | false | true | 'PyPI package index' | :success + :public | :guest | false | true | 'PyPI package index' | :success + :public | :developer | false | false | 'PyPI package index' | :success + :public | :guest | false | false | 'PyPI package index' | :success + :public | :anonymous | false | true | 'PyPI package index' | :success + :private | :developer | true | true | 'PyPI package index' | :success + :private | :guest | true | true | 'process PyPI api request' | :forbidden + :private | :developer | true | false | 'process PyPI api request' | :unauthorized + :private | :guest | true | false | 'process PyPI api request' | :unauthorized + :private | :developer | false | true | 'process PyPI api request' | :not_found + :private | :guest | false | true | 'process PyPI api request' | :not_found + :private | :developer | false | false | 'process PyPI api request' | :unauthorized + :private | :guest | false | false | 'process PyPI api request' | :unauthorized + :private | :anonymous | false | true | 'process PyPI api request' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s)) + group.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end +end + RSpec.shared_examples 'pypi file download endpoint' do using RSpec::Parameterized::TableSyntax diff --git a/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb b/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb new file mode 100644 index 00000000000..31218b104bd --- /dev/null +++ b/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'avoids N+1 queries on environment detail page' do + it 'avoids N+1 queries', :use_sql_query_cache do + create_deployment_with_associations(commit_depth: 19) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get project_environment_path(project, environment), params: environment_params + end + + 18.downto(0).each { |n| create_deployment_with_associations(commit_depth: n) } + + # N+1s exist for loading commit emails and users + expect do + get project_environment_path(project, environment), params: environment_params + end.not_to exceed_all_query_limit(control).with_threshold(9) + end +end diff --git a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb index e1baa594f3c..6d59943d91c 100644 --- a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb +++ b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb @@ -8,9 +8,8 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do create_environment_with_associations(project) create_environment_with_associations(project) - # Fix N+1 queries introduced by multi stop_actions for environment. - # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 - relax_count = 14 + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/363317 + relax_count = 1 expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count + relax_count) end @@ -23,9 +22,8 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do create_environment_with_associations(project) create_environment_with_associations(project) - # Fix N+1 queries introduced by multi stop_actions for environment. - # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 - relax_count = 14 + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/363317 + relax_count = 1 expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count + relax_count) end diff --git a/spec/support/shared_examples/services/alert_management_shared_examples.rb b/spec/support/shared_examples/services/alert_management_shared_examples.rb index 23aee912d2d..f644f1a1687 100644 --- a/spec/support/shared_examples/services/alert_management_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb @@ -32,7 +32,7 @@ RSpec.shared_context 'incident management settings enabled' do end before do - allow(ProjectServiceWorker).to receive(:perform_async) + allow(Integrations::ExecuteWorker).to receive(:perform_async) allow(service) .to receive(:incident_management_setting) .and_return(incident_management_setting) diff --git a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb index 9a3a0cc9cc8..ed05a150f8b 100644 --- a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb +++ b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb @@ -3,17 +3,17 @@ RSpec.shared_examples 'items list service' do it 'avoids N+1' do params = { board_id: board.id } - control = ActiveRecord::QueryRecorder.new { described_class.new(parent, user, params).execute } + control = ActiveRecord::QueryRecorder.new { list_service(params).execute } new_list - expect { described_class.new(parent, user, params).execute }.not_to exceed_query_limit(control) + expect { list_service(params).execute }.not_to exceed_query_limit(control) end - it 'returns opened items when list_id is missing' do + it 'returns opened items when list_id and list are missing' do params = { board_id: board.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(backlog_items) end @@ -21,7 +21,7 @@ RSpec.shared_examples 'items list service' do it 'returns opened items when listing items from Backlog' do params = { board_id: board.id, id: backlog.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(backlog_items) end @@ -29,7 +29,7 @@ RSpec.shared_examples 'items list service' do it 'returns opened items that have label list applied when listing items from a label list' do params = { board_id: board.id, id: list1.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(list1_items) end @@ -37,20 +37,24 @@ RSpec.shared_examples 'items list service' do it 'returns closed items when listing items from Closed sorted by closed_at in descending order' do params = { board_id: board.id, id: closed.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to eq(closed_items) end it 'raises an error if the list does not belong to the board' do list = create(list_factory) # rubocop:disable Rails/SaveBang - service = described_class.new(parent, user, board_id: board.id, id: list.id) + params = { board_id: board.id, id: list.id } + + service = list_service(params) expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) end - it 'raises an error if list id is invalid' do - service = described_class.new(parent, user, board_id: board.id, id: nil) + it 'raises an error if list and list id are invalid or missing' do + params = { board_id: board.id, id: nil, list: nil } + + service = list_service(params) expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) end @@ -58,8 +62,22 @@ RSpec.shared_examples 'items list service' do it 'returns items from all lists if :all_list is used' do params = { board_id: board.id, all_lists: true } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(all_items) end + + it 'returns opened items that have label list applied when using list param' do + params = { board_id: board.id, list: list1 } + + items = list_service(params).execute + + expect(items).to match_array(list1_items) + end + + def list_service(params) + args = [parent, user].push(params) + + described_class.new(*args) + end end diff --git a/spec/support/shared_examples/views/pagination_shared_examples.rb b/spec/support/shared_examples/views/pagination_shared_examples.rb new file mode 100644 index 00000000000..3932f320859 --- /dev/null +++ b/spec/support/shared_examples/views/pagination_shared_examples.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'correct pagination' do + it 'paginates correctly to page 3 and back' do + expect(page).to have_selector(item_selector, count: per_page) + page1_item_text = page.find(item_selector).text + click_next_page(next_button_selector) + + expect(page).to have_selector(item_selector, count: per_page) + page2_item_text = page.find(item_selector).text + click_next_page(next_button_selector) + + expect(page).to have_selector(item_selector, count: per_page) + page3_item_text = page.find(item_selector).text + click_prev_page(prev_button_selector) + + expect(page3_item_text).not_to eql(page2_item_text) + expect(page.find(item_selector).text).to eql(page2_item_text) + + click_prev_page(prev_button_selector) + + expect(page.find(item_selector).text).to eql(page1_item_text) + expect(page).to have_selector(item_selector, count: per_page) + end + + def click_next_page(next_button_selector) + page.find(next_button_selector).click + wait_for_requests + end + + def click_prev_page(prev_button_selector) + page.find(prev_button_selector).click + wait_for_requests + end +end diff --git a/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb index 7fdf049a823..8ecb04bfdd6 100644 --- a/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb @@ -42,159 +42,195 @@ RSpec.shared_examples 'it runs background migration jobs' do |tracking_database| describe '#perform' do let(:worker) { described_class.new } - before do - allow(worker).to receive(:jid).and_return(1) - allow(worker).to receive(:always_perform?).and_return(false) + context 'when execute_background_migrations feature flag is disabled' do + before do + stub_feature_flags(execute_background_migrations: false) + end - allow(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false) - end + it 'does not perform the job, reschedules it in the future, and logs a message' do + expect(worker).not_to receive(:perform_with_connection) - it 'performs jobs using the coordinator for the worker' do - expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| - allow(coordinator).to receive(:with_shared_connection).and_yield + expect(Sidekiq.logger).to receive(:info) do |payload| + expect(payload[:class]).to eq(described_class.name) + expect(payload[:database]).to eq(tracking_database) + expect(payload[:message]).to match(/skipping execution, migration rescheduled/) + end - expect(coordinator.worker_class).to eq(described_class) - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - end + lease_attempts = 3 + delay = described_class::BACKGROUND_MIGRATIONS_DELAY + job_args = [10, 20] - worker.perform('Foo', [10, 20]) - end + freeze_time do + worker.perform('Foo', job_args, lease_attempts) - context 'when lease can be obtained' do - let(:coordinator) { double('job coordinator') } + job = described_class.jobs.find { |job| job['args'] == ['Foo', job_args, lease_attempts] } + expect(job).to be, "Expected the job to be rescheduled with (#{job_args}, #{lease_attempts}), but it was not." + expected_time = delay.to_i + Time.now.to_i + expect(job['at']).to eq(expected_time), + "Expected the job to be rescheduled in #{expected_time} seconds, " \ + "but it was rescheduled in #{job['at']} seconds." + end + end + end + + context 'when execute_background_migrations feature flag is enabled' do before do - allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) - .with(tracking_database) - .and_return(coordinator) + stub_feature_flags(execute_background_migrations: true) - allow(coordinator).to receive(:with_shared_connection).and_yield + allow(worker).to receive(:jid).and_return(1) + allow(worker).to receive(:always_perform?).and_return(false) + + allow(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false) end - it 'sets up the shared connection before checking replication' do - expect(coordinator).to receive(:with_shared_connection).and_yield.ordered - expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false).ordered + it 'performs jobs using the coordinator for the worker' do + expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| + allow(coordinator).to receive(:with_shared_connection).and_yield - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + expect(coordinator.worker_class).to eq(described_class) + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + end worker.perform('Foo', [10, 20]) end - it 'performs a background migration' do - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + context 'when lease can be obtained' do + let(:coordinator) { double('job coordinator') } - worker.perform('Foo', [10, 20]) - end + before do + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with(tracking_database) + .and_return(coordinator) + + allow(coordinator).to receive(:with_shared_connection).and_yield + end + + it 'sets up the shared connection before checking replication' do + expect(coordinator).to receive(:with_shared_connection).and_yield.ordered + expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false).ordered - context 'when lease_attempts is 1' do - it 'performs a background migration' do expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - worker.perform('Foo', [10, 20], 1) + worker.perform('Foo', [10, 20]) end - end - it 'can run scheduled job and retried job concurrently' do - expect(coordinator) - .to receive(:perform) - .with('Foo', [10, 20]) - .exactly(2).time - - worker.perform('Foo', [10, 20]) - worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1) - end + it 'performs a background migration' do + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - it 'sets the class that will be executed as the caller_id' do - expect(coordinator).to receive(:perform) do - expect(Gitlab::ApplicationContext.current).to include('meta.caller_id' => 'Foo') + worker.perform('Foo', [10, 20]) end - worker.perform('Foo', [10, 20]) - end - end + context 'when lease_attempts is 1' do + it 'performs a background migration' do + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - context 'when lease not obtained (migration of same class was performed recently)' do - let(:timeout) { described_class.minimum_interval } - let(:lease_key) { "#{described_class.name}:Foo" } - let(:coordinator) { double('job coordinator') } + worker.perform('Foo', [10, 20], 1) + end + end - before do - allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) - .with(tracking_database) - .and_return(coordinator) + it 'can run scheduled job and retried job concurrently' do + expect(coordinator) + .to receive(:perform) + .with('Foo', [10, 20]) + .exactly(2).time - allow(coordinator).to receive(:with_shared_connection).and_yield + worker.perform('Foo', [10, 20]) + worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1) + end - expect(coordinator).not_to receive(:perform) + it 'sets the class that will be executed as the caller_id' do + expect(coordinator).to receive(:perform) do + expect(Gitlab::ApplicationContext.current).to include('meta.caller_id' => 'Foo') + end - Gitlab::ExclusiveLease.new(lease_key, timeout: timeout).try_obtain + worker.perform('Foo', [10, 20]) + end end - it 'reschedules the migration and decrements the lease_attempts' do - expect(described_class) - .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) + context 'when lease not obtained (migration of same class was performed recently)' do + let(:timeout) { described_class.minimum_interval } + let(:lease_key) { "#{described_class.name}:Foo" } + let(:coordinator) { double('job coordinator') } - worker.perform('Foo', [10, 20], 5) - end + before do + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with(tracking_database) + .and_return(coordinator) - context 'when lease_attempts is 1' do - let(:lease_key) { "#{described_class.name}:Foo:retried" } + allow(coordinator).to receive(:with_shared_connection).and_yield + + expect(coordinator).not_to receive(:perform) + + Gitlab::ExclusiveLease.new(lease_key, timeout: timeout).try_obtain + end it 'reschedules the migration and decrements the lease_attempts' do expect(described_class) .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 0) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) - worker.perform('Foo', [10, 20], 1) + worker.perform('Foo', [10, 20], 5) end - end - context 'when lease_attempts is 0' do - let(:lease_key) { "#{described_class.name}:Foo:retried" } + context 'when lease_attempts is 1' do + let(:lease_key) { "#{described_class.name}:Foo:retried" } - it 'gives up performing the migration' do - expect(described_class).not_to receive(:perform_in) - expect(Sidekiq.logger).to receive(:warn).with( - class: 'Foo', - message: 'Job could not get an exclusive lease after several tries. Giving up.', - job_id: 1) + it 'reschedules the migration and decrements the lease_attempts' do + expect(described_class) + .to receive(:perform_in) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 0) - worker.perform('Foo', [10, 20], 0) + worker.perform('Foo', [10, 20], 1) + end end - end - end - context 'when database is not healthy' do - before do - expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(true) - end + context 'when lease_attempts is 0' do + let(:lease_key) { "#{described_class.name}:Foo:retried" } - it 'reschedules a migration if the database is not healthy' do - expect(described_class) - .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) + it 'gives up performing the migration' do + expect(described_class).not_to receive(:perform_in) + expect(Sidekiq.logger).to receive(:warn).with( + class: 'Foo', + message: 'Job could not get an exclusive lease after several tries. Giving up.', + job_id: 1) - worker.perform('Foo', [10, 20]) + worker.perform('Foo', [10, 20], 0) + end + end end - it 'increments the unhealthy counter' do - counter = Gitlab::Metrics.counter(:background_migration_database_health_reschedules, 'msg') + context 'when database is not healthy' do + before do + expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(true) + end - expect(described_class).to receive(:perform_in) + it 'reschedules a migration if the database is not healthy' do + expect(described_class) + .to receive(:perform_in) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) - expect { worker.perform('Foo', [10, 20]) }.to change { counter.get(db_config_name: tracking_database) }.by(1) - end + worker.perform('Foo', [10, 20]) + end + + it 'increments the unhealthy counter' do + counter = Gitlab::Metrics.counter(:background_migration_database_health_reschedules, 'msg') + + expect(described_class).to receive(:perform_in) + + expect { worker.perform('Foo', [10, 20]) }.to change { counter.get(db_config_name: tracking_database) }.by(1) + end - context 'when lease_attempts is 0' do - it 'gives up performing the migration' do - expect(described_class).not_to receive(:perform_in) - expect(Sidekiq.logger).to receive(:warn).with( - class: 'Foo', - message: 'Database was unhealthy after several tries. Giving up.', - job_id: 1) + context 'when lease_attempts is 0' do + it 'gives up performing the migration' do + expect(described_class).not_to receive(:perform_in) + expect(Sidekiq.logger).to receive(:warn).with( + class: 'Foo', + message: 'Database was unhealthy after several tries. Giving up.', + job_id: 1) - worker.perform('Foo', [10, 20], 0) + worker.perform('Foo', [10, 20], 0) + end end end end diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb index 3d4e840fe2d..54962eac100 100644 --- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database, feature_flag:| +RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database| include ExclusiveLeaseHelpers describe 'defining the job attributes' do @@ -40,13 +40,17 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end describe '.enabled?' do - it 'does not raise an error' do - expect { described_class.enabled? }.not_to raise_error - end + it 'returns true when execute_batched_migrations_on_schedule feature flag is enabled' do + stub_feature_flags(execute_batched_migrations_on_schedule: true) - it 'returns true' do expect(described_class.enabled?).to be_truthy end + + it 'returns false when execute_batched_migrations_on_schedule feature flag is disabled' do + stub_feature_flags(execute_batched_migrations_on_schedule: false) + + expect(described_class.enabled?).to be_falsey + end end describe '#perform' do @@ -86,7 +90,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d context 'when the feature flag is disabled' do before do - stub_feature_flags(feature_flag => false) + stub_feature_flags(execute_batched_migrations_on_schedule: false) end it 'does nothing' do @@ -98,10 +102,26 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end context 'when the feature flag is enabled' do + let(:base_model) { Gitlab::Database.database_base_models[tracking_database] } + before do - stub_feature_flags(feature_flag => true) + stub_feature_flags(execute_batched_migrations_on_schedule: true) - allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration).and_return(nil) + allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration) + .with(connection: base_model.connection) + .and_return(nil) + end + + context 'when database config is shared' do + it 'does nothing' do + expect(Gitlab::Database).to receive(:db_config_share_with) + .with(base_model.connection_db_config).and_return('main') + + expect(worker).not_to receive(:active_migration) + expect(worker).not_to receive(:run_active_migration) + + worker.perform + end end context 'when no active migrations exist' do @@ -121,6 +141,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d before do allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration) + .with(connection: base_model.connection) .and_return(migration) allow(migration).to receive(:interval_elapsed?).with(variance: interval_variance).and_return(true) @@ -222,6 +243,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end end + let(:gitlab_schema) { "gitlab_#{tracking_database}" } let!(:migration) do create( :batched_background_migration, @@ -232,10 +254,12 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d batch_size: batch_size, sub_batch_size: sub_batch_size, job_class_name: 'ExampleDataMigration', - job_arguments: [1] + job_arguments: [1], + gitlab_schema: gitlab_schema ) end + let(:base_model) { Gitlab::Database.database_base_models[tracking_database] } let(:table_name) { 'example_data' } let(:batch_size) { 5 } let(:sub_batch_size) { 2 } diff --git a/spec/support/shared_examples/workers/idempotency_shared_examples.rb b/spec/support/shared_examples/workers/idempotency_shared_examples.rb index 9d9b371d61a..be43ea7d5f0 100644 --- a/spec/support/shared_examples/workers/idempotency_shared_examples.rb +++ b/spec/support/shared_examples/workers/idempotency_shared_examples.rb @@ -20,7 +20,11 @@ RSpec.shared_examples 'an idempotent worker' do # Avoid stubbing calls for a more accurate run. subject do - defined?(job_args) ? perform_multiple(job_args) : perform_multiple + if described_class.include?(::Gitlab::EventStore::Subscriber) + event_worker + else + standard_worker + end end it 'is labeled as idempotent' do @@ -30,4 +34,12 @@ RSpec.shared_examples 'an idempotent worker' do it 'performs multiple times sequentially without raising an exception' do expect { subject }.not_to raise_error end + + def event_worker + consume_event(subscriber: described_class, event: event) + end + + def standard_worker + defined?(job_args) ? perform_multiple(job_args) : perform_multiple + end end |